Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 162 additions & 2 deletions app/components/device/new/location-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '~/components/ui/label'
import { BaseMap } from '~/components/base-map'
import { LOCATION_LIMITS, isValidLocation } from '~/lib/location'
import {
DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS,
DEFAULT_LOCATION_PRIVACY_RADIUS_METERS,
LOCATION_LIMITS,
LOCATION_PRIVACY_DISTANCE_PRESETS,
isValidLocation,
type LocationPrivacyData,
} from '~/lib/location'

export function LocationStep() {
const mapRef = useRef<MapRef | null>(null)
Expand All @@ -24,6 +31,13 @@ export function LocationStep() {
const { t } = useTranslation('newdevice')
const savedLatitude = watch('latitude')
const savedLongitude = watch('longitude')
const locationPrivacy = watch('locationPrivacy') ?? 'masked'
const locationPrivacyMinDistanceMeters =
watch('locationPrivacyMinDistanceMeters') ??
DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS
const locationPrivacyRadiusMeters =
watch('locationPrivacyRadiusMeters') ??
DEFAULT_LOCATION_PRIVACY_RADIUS_METERS

const [marker, setMarker] = useState<{
latitude: number | string
Expand All @@ -33,6 +47,33 @@ export function LocationStep() {
longitude: savedLongitude || '',
})

useEffect(() => {
if (!locationPrivacy) {
setValue('locationPrivacy', 'masked', { shouldValidate: true })
}
if (!locationPrivacyMinDistanceMeters) {
setValue(
'locationPrivacyMinDistanceMeters',
DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS,
{
shouldValidate: true,
},
)
}
if (!locationPrivacyRadiusMeters) {
setValue(
'locationPrivacyRadiusMeters',
DEFAULT_LOCATION_PRIVACY_RADIUS_METERS,
{ shouldValidate: true },
)
}
}, [
locationPrivacy,
locationPrivacyMinDistanceMeters,
locationPrivacyRadiusMeters,
setValue,
])

useEffect(() => {
if (savedLatitude !== undefined && savedLongitude !== undefined) {
setMarker({
Expand All @@ -42,6 +83,43 @@ export function LocationStep() {
}
}, [savedLatitude, savedLongitude])

const onLocationPrivacyChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setValue(
'locationPrivacy',
event.target.value === 'exact' ? 'exact' : 'masked',
{ shouldValidate: true },
)
}

const onLocationPrivacyPresetChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const [minDistance, maxDistance] = event.target.value.split(':').map(Number)

const preset = LOCATION_PRIVACY_DISTANCE_PRESETS.find(
(candidate) =>
candidate.min === minDistance && candidate.max === maxDistance,
)

if (!preset) return

setValue(
'locationPrivacyMinDistanceMeters',
preset.min as LocationPrivacyData['locationPrivacyMinDistanceMeters'],
{ shouldValidate: true },
)
setValue(
'locationPrivacyRadiusMeters',
preset.max as LocationPrivacyData['locationPrivacyRadiusMeters'],
{ shouldValidate: true },
)
}

const formatDistance = (meters: number) =>
meters >= 1000 ? `${meters / 1000} km` : `${meters} m`

const handleLatitudeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim()
const parsedValue = parseFloat(value)
Expand Down Expand Up @@ -136,7 +214,7 @@ export function LocationStep() {
</BaseMap>
</div>

<div className="bg-background flex w-full items-center justify-around p-4">
<div className="bg-background grid w-full gap-4 p-4 lg:grid-cols-2">
<div>
<Label htmlFor="latitude">{t('latitude')}</Label>
<Input
Expand Down Expand Up @@ -178,6 +256,88 @@ export function LocationStep() {
</p>
) : null}
</div>

<div className="space-y-3 lg:col-span-2">
<fieldset>
<legend className="text-sm font-medium">
{t('public_location')}
</legend>

<div className="mt-2 grid gap-3 md:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-md border p-3">
<input
type="radio"
value="masked"
checked={locationPrivacy !== 'exact'}
onChange={onLocationPrivacyChange}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium">
{t('show_approximate_location')}
</span>
<span className="text-muted-foreground mt-1 block text-sm">
{t('show_approximate_location_description')}
</span>
</span>
</label>

<label className="flex cursor-pointer gap-3 rounded-md border p-3">
<input
type="radio"
value="exact"
checked={locationPrivacy === 'exact'}
onChange={onLocationPrivacyChange}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium">
{t('show_exact_location')}
</span>
<span className="text-muted-foreground mt-1 block text-sm">
{t('show_exact_location_description')}
</span>
</span>
</label>
</div>
</fieldset>

<div className="max-w-xs">
<Label htmlFor="locationPrivacyPreset">
{t('approximation_area')}
</Label>
<select
id="locationPrivacyPreset"
value={`${locationPrivacyMinDistanceMeters}:${locationPrivacyRadiusMeters}`}
onChange={onLocationPrivacyPresetChange}
disabled={locationPrivacy === 'exact'}
className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm disabled:bg-gray-100 disabled:text-gray-500"
>
{LOCATION_PRIVACY_DISTANCE_PRESETS.map((preset) => (
<option
key={`${preset.min}:${preset.max}`}
value={`${preset.min}:${preset.max}`}
>
{t('distance_range', {
min: formatDistance(preset.min),
max: formatDistance(preset.max),
})}
</option>
))}
</select>
<input type="hidden" {...register('locationPrivacy')} />
<input
type="hidden"
{...register('locationPrivacyMinDistanceMeters')}
/>
<input type="hidden" {...register('locationPrivacyRadiusMeters')} />
{errors.locationPrivacyMinDistanceMeters?.message ? (
<p className="mt-1 text-sm text-red-600">
{String(errors.locationPrivacyMinDistanceMeters.message)}
</p>
) : null}
</div>
</div>
</div>
</div>
)
Expand Down
14 changes: 10 additions & 4 deletions app/components/device/new/new-device-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import {
import { useToast } from '~/components/ui/use-toast'
import { DeviceModelEnum } from '~/db/schema/enum'
import { type loader } from '~/routes/device.new'
import { locationSchema, type LocationData } from '~/lib/location'
import {
locationPrivacySchema,
locationSchema,
type LocationData,
type LocationPrivacyData,
} from '~/lib/location'
import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general'

const deviceSchema = z.object({
Expand All @@ -46,10 +51,11 @@ const sensorsSchema = z.object({
})

const advancedSchema = z.record(z.string(), z.any())
const deviceLocationSchema = locationSchema.and(locationPrivacySchema)

const formSchema = z.union([
generalInfoSchema,
locationSchema,
deviceLocationSchema,
deviceSchema,
sensorsSchema,
advancedSchema,
Expand All @@ -67,7 +73,7 @@ export const Stepper = defineStepper(
id: 'location',
label: 'location',
infoKey: 'location_info_text',
schema: locationSchema,
schema: deviceLocationSchema,
index: 1,
},
{
Expand Down Expand Up @@ -106,7 +112,7 @@ type AdvancedData = z.infer<typeof advancedSchema>

type FormData =
| GeneralInfoData
| LocationData
| (LocationData & LocationPrivacyData)
| DeviceData
| SensorData
| AdvancedData
Expand Down
11 changes: 11 additions & 0 deletions app/components/device/new/summary-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export function SummaryInfo() {
const { getValues } = useFormContext()
const formData = getValues()
const { t } = useTranslation('newdevice')
const publicLocationValue =
formData.locationPrivacy === 'exact'
? t('exact_location')
: t('approximate_location_summary', {
min: formData.locationPrivacyMinDistanceMeters,
max: formData.locationPrivacyRadiusMeters,
})

const sections = [
{
Expand All @@ -31,6 +38,10 @@ export function SummaryInfo() {
label: 'longitude',
value: parseFloat(formData.longitude).toFixed(4),
},
{
label: 'public_location',
value: publicLocationValue,
},
],
},
{
Expand Down
4 changes: 4 additions & 0 deletions app/db/drizzle/0046_chemical_maelstrom.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE "device" ADD COLUMN "location_privacy" text DEFAULT 'masked' NOT NULL;--> statement-breakpoint
ALTER TABLE "device" ADD COLUMN "location_privacy_min_distance_meters" integer DEFAULT 20 NOT NULL;--> statement-breakpoint
ALTER TABLE "device" ADD COLUMN "location_privacy_radius_meters" integer DEFAULT 50 NOT NULL;--> statement-breakpoint
ALTER TABLE "device" ADD COLUMN "location_privacy_method" text DEFAULT 'stable-donut-displacement-v1' NOT NULL;
Loading
Loading