Skip to content
4 changes: 2 additions & 2 deletions app/components/device/new/general-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function GeneralInfoStep() {
- Temperature

[${t('project_website')}](https://example.com)`}
className="min-h-[220px] w-full rounded-md border p-3 font-mono text-sm"
className="min-h-55 w-full rounded-md border p-3 font-mono text-sm"
/>
<div className="text-muted-foreground text-sm">
{description.length} / 5000
Expand All @@ -123,7 +123,7 @@ export function GeneralInfoStep() {

<div className="space-y-2">
<Label>{t('preview')}</Label>
<div className="min-h-[220px] rounded-md border p-3">
<div className="min-h-55 rounded-md border p-3">
{description.trim() ? (
<MarkdownContent>{description}</MarkdownContent>
) : (
Expand Down
41 changes: 27 additions & 14 deletions app/components/device/new/location-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ 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'

export function LocationStep() {
const mapRef = useRef<MapRef | null>(null)
const { register, setValue, watch } = useFormContext()
const {
register,
setValue,
watch,
formState: { errors },
} = useFormContext()
const { t } = useTranslation('newdevice')
const savedLatitude = watch('latitude')
const savedLongitude = watch('longitude')
Expand Down Expand Up @@ -104,7 +110,10 @@ export function LocationStep() {
}}
onClick={onMapClick}
>
{marker.latitude && marker.longitude && (
{isValidLocation({
latitude: Number(marker.latitude),
longitude: Number(marker.longitude),
}) && (
<Marker
latitude={Number(marker.latitude)}
longitude={Number(marker.longitude)}
Expand All @@ -129,17 +138,19 @@ export function LocationStep() {
id="latitude"
type="number"
step="any"
{...register('latitude', {
valueAsNumber: true,
required: 'Latitude is required',
min: -90,
max: 90,
})}
min={LOCATION_LIMITS.latitude.min}
max={LOCATION_LIMITS.latitude.max}
{...register('latitude')}
value={marker.latitude === '' ? '' : String(marker.latitude)}
onChange={handleLatitudeChange}
placeholder={t('enter latitude')}
className="w-full rounded-md border p-2"
/>
{errors.latitude?.message ? (
<p className="mt-1 text-sm text-red-600">
{String(errors.latitude.message)}
</p>
) : null}
</div>

<div>
Expand All @@ -148,17 +159,19 @@ export function LocationStep() {
id="longitude"
type="number"
step="any"
{...register('longitude', {
valueAsNumber: true,
required: 'Longitude is required',
min: -180,
max: 180,
})}
min={LOCATION_LIMITS.longitude.min}
max={LOCATION_LIMITS.longitude.max}
{...register('longitude')}
value={marker.longitude === '' ? '' : String(marker.longitude)}
onChange={handleLongitudeChange}
placeholder={t('enter longitude')}
className="w-full rounded-md border p-2"
/>
{errors.longitude?.message ? (
<p className="mt-1 text-sm text-red-600">
{String(errors.longitude.message)}
</p>
) : null}
</div>
</div>
</div>
Expand Down
58 changes: 2 additions & 56 deletions app/components/device/new/new-device-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,60 +29,8 @@ import {
import { useToast } from '~/components/ui/use-toast'
import { DeviceModelEnum } from '~/db/schema/enum'
import { type loader } from '~/routes/device.new'

const generalInfoSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.min(1, 'Name is required'),
description: z
.string()
.max(5000, 'Description should not exceed 5000 characters')
.optional()
.nullable(),
exposure: z.enum(['indoor', 'outdoor', 'mobile', 'unknown'], {
error: () => 'Exposure is required',
}),
temporaryExpirationDate: z
.string()
.optional()
.transform((date) => (date ? new Date(date) : undefined)) // Transform string to Date
.refine(
(date) =>
!date || date <= new Date(Date.now() + 31 * 24 * 60 * 60 * 1000),
{
message: 'Temporary expiration date must be within 1 month from now',
},
),
tags: z
.array(
z.object({
value: z.string(),
}),
)
.optional(),
})

const locationSchema = z.object({
latitude: z.coerce
.number({
error: (issue) =>
issue.input === undefined
? 'Latitude is required'
: 'Latitude must be a valid number',
})
.min(-90, 'Latitude must be greater than or equal to -90')
.max(90, 'Latitude must be less than or equal to 90'),
longitude: z.coerce
.number({
error: (issue) =>
issue.input === undefined
? 'Longitude is required'
: 'Longitude must be a valid number',
})
.min(-180, 'Longitude must be greater than or equal to -180')
.max(180, 'Longitude must be less than or equal to 180'),
})
import { locationSchema, type LocationData } from '~/lib/location'
import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general'

const deviceSchema = z.object({
model: z.enum(DeviceModelEnum.enumValues, {
Expand Down Expand Up @@ -152,8 +100,6 @@ export const Stepper = defineStepper(
},
)

type GeneralInfoData = z.infer<typeof generalInfoSchema>
type LocationData = z.infer<typeof locationSchema>
type DeviceData = z.infer<typeof deviceSchema>
type SensorData = z.infer<typeof sensorsSchema>
type AdvancedData = z.infer<typeof advancedSchema>
Expand Down
21 changes: 11 additions & 10 deletions app/db/models/profile.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,18 @@ export async function getProfileByUsername(username: string) {
export async function updateProfile(
id: Profile['id'],
displayName: Profile['displayName'],
visibility: Profile['public'],
visibility: boolean,
) {
try {
const result = await drizzleClient
.update(profile)
.set({ displayName, public: visibility })
.where(eq(profile.id, id))
return result
} catch (error) {
throw error
}
const [updatedProfile] = await drizzleClient
.update(profile)
.set({
displayName,
public: visibility,
})
.where(eq(profile.id, id))
.returning()

return updatedProfile
}

export async function createProfile(
Expand Down
139 changes: 139 additions & 0 deletions app/hooks/use-autosave-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFetcher } from 'react-router'

export type AutosaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error'

type UseAutosaveFetcherOptions<TValues, TData> = {
values: TValues
lastSavedValues: TValues
debounceMs?: number
enabled?: boolean
validate?: (values: TValues) => boolean
getPayload: (values: TValues) => Record<string, string>
isSuccess: (data: TData) => boolean
getSavedValues?: (data: TData, submittedValues: TValues) => TValues
onSuccess?: (data: TData) => void
onError?: (data: TData) => void
}

export function useAutosaveFetcher<TValues, TData>({
values,
lastSavedValues,
debounceMs = 700,
enabled = true,
validate,
getPayload,
isSuccess,
getSavedValues,
onSuccess,
onError,
}: UseAutosaveFetcherOptions<TValues, TData>) {
const fetcher = useFetcher()

const lastSavedRef = useRef(lastSavedValues)
const lastSubmittedRef = useRef<TValues | null>(null)
const processedDataRef = useRef<TData | null>(null)

const [saveCount, setSaveCount] = useState(0)
const [hasError, setHasError] = useState(false)

const valuesJson = JSON.stringify(values)
const lastSavedJson = JSON.stringify(lastSavedRef.current)

const hasChanges = valuesJson !== lastSavedJson

const isSaving = fetcher.state === 'submitting' || fetcher.state === 'loading'

const status: AutosaveStatus = isSaving
? 'saving'
: hasError
? 'error'
: hasChanges
? 'dirty'
: saveCount > 0
? 'saved'
: 'idle'

const submit = useCallback(
(nextValues: TValues) => {
lastSubmittedRef.current = nextValues
setHasError(false)

fetcher.submit(getPayload(nextValues), {
method: 'post',
})
},
[fetcher, getPayload],
)

useEffect(() => {
if (!enabled) return
if (!hasChanges) return
if (isSaving) return
if (validate && !validate(values)) return

const timeout = window.setTimeout(() => {
submit(values)
}, debounceMs)

return () => window.clearTimeout(timeout)
}, [
enabled,
hasChanges,
isSaving,
valuesJson,
values,
debounceMs,
validate,
submit,
])

useEffect(() => {
if (fetcher.state !== 'idle') return
if (fetcher.data == null) return
if (processedDataRef.current === fetcher.data) return

processedDataRef.current = fetcher.data

const data = fetcher.data
const submittedValues = lastSubmittedRef.current

if (!submittedValues) return

if (isSuccess(data)) {
lastSavedRef.current = getSavedValues
? getSavedValues(data, submittedValues)
: submittedValues

setHasError(false)
setSaveCount((count) => count + 1)
onSuccess?.(data)
} else {
setHasError(true)
onError?.(data)
}
}, [
fetcher.state,
fetcher.data,
getSavedValues,
isSuccess,
onSuccess,
onError,
])

const resetLastSaved = useCallback((nextValues: TValues) => {
lastSavedRef.current = nextValues
setHasError(false)
setSaveCount((count) => count + 1)
}, [])

return {
fetcher,
submit,
status,
isSaving,
hasChanges,
resetLastSaved,
lastSavedRef,
}
}
53 changes: 53 additions & 0 deletions app/lib/device-general.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { z } from 'zod'

export const generalInfoSchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name is required')
.min(2, 'Name must be at least 2 characters'),

description: z
.string()
.max(5000, 'Description should not exceed 5000 characters')
.optional()
.nullable(),

exposure: z.enum(['indoor', 'outdoor', 'mobile', 'unknown'], {
error: () => 'Exposure is required',
}),

temporaryExpirationDate: z
.string()
.optional()
.transform((date) => (date ? new Date(date) : undefined))
.refine(
(date) =>
!date || date <= new Date(Date.now() + 31 * 24 * 60 * 60 * 1000),
{
message: 'Temporary expiration date must be within 1 month from now',
},
),

tags: z
.array(
z.object({
value: z.string(),
}),
)
.optional(),
})

export type GeneralInfoData = z.infer<typeof generalInfoSchema>

export type GeneralInfoErrors = {
form?: string
name?: string
description?: string
exposure?: string
temporaryExpirationDate?: string
tags?: string
}



Loading
Loading