diff --git a/app/components/autosave-status.text.tsx b/app/components/autosave-status.text.tsx new file mode 100644 index 00000000..ae004e27 --- /dev/null +++ b/app/components/autosave-status.text.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' +import { type AutosaveStatus } from '~/hooks/use-autosave-fetcher' + +type AutosaveStatusTextProps = { + status: AutosaveStatus + hasValidationErrors?: boolean + namespace?: string + className?: string +} + +export function AutosaveStatusText({ + status, + hasValidationErrors = false, + namespace, + className = 'mt-2 min-h-5 text-sm', +}: AutosaveStatusTextProps) { + const { t } = useTranslation(namespace) + + const shouldHideBecauseInvalid = + hasValidationErrors && (status === 'dirty' || status === 'saved') + + if (shouldHideBecauseInvalid || status === 'idle') { + return
+ } + + const contentByStatus: Record< + Exclude, + { + label: string + className: string + } + > = { + saving: { + label: t('saving'), + className: 'text-gray-500', + }, + error: { + label: t('autosave_failed'), + className: 'text-red-600', + }, + dirty: { + label: t('unsaved_changes'), + className: 'text-gray-500', + }, + saved: { + label: t('saved'), + className: 'text-green-500', + }, + } + + const content = contentByStatus[status] + + return ( +
+

{content.label}

+
+ ) +} diff --git a/app/components/device/new/general-info.tsx b/app/components/device/new/general-info.tsx index 16ac045e..3bc640f7 100644 --- a/app/components/device/new/general-info.tsx +++ b/app/components/device/new/general-info.tsx @@ -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" />
{description.length} / 5000 @@ -123,7 +123,7 @@ export function GeneralInfoStep() {
-
+
{description.trim() ? ( {description} ) : ( diff --git a/app/components/device/new/location-info.tsx b/app/components/device/new/location-info.tsx index e64e33fc..02a483c2 100644 --- a/app/components/device/new/location-info.tsx +++ b/app/components/device/new/location-info.tsx @@ -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(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') @@ -104,7 +110,10 @@ export function LocationStep() { }} onClick={onMapClick} > - {marker.latitude && marker.longitude && ( + {isValidLocation({ + latitude: Number(marker.latitude), + longitude: Number(marker.longitude), + }) && ( + {errors.latitude?.message ? ( +

+ {String(errors.latitude.message)} +

+ ) : null}
@@ -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 ? ( +

+ {String(errors.longitude.message)} +

+ ) : null}
diff --git a/app/components/device/new/new-device-stepper.tsx b/app/components/device/new/new-device-stepper.tsx index a291f65d..e2b99531 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -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, { @@ -152,8 +100,6 @@ export const Stepper = defineStepper( }, ) -type GeneralInfoData = z.infer -type LocationData = z.infer type DeviceData = z.infer type SensorData = z.infer type AdvancedData = z.infer diff --git a/app/components/header/nav-bar/filter-options/filter-options.tsx b/app/components/header/nav-bar/filter-options/filter-options.tsx index 941c6452..f5a3758d 100644 --- a/app/components/header/nav-bar/filter-options/filter-options.tsx +++ b/app/components/header/nav-bar/filter-options/filter-options.tsx @@ -1,10 +1,8 @@ import { Label } from '~/components/ui/label' import { ToggleGroup, ToggleGroupItem } from '~/components/ui/toggle-group' -import { - type DeviceExposureType, - type DeviceStatusType, -} from '~/db/schema/enum' + import { useTranslation } from 'react-i18next' +import { DeviceExposureType, DeviceStatusType } from '~/lib/device-enums' interface FilterOptionsProps { exposure: DeviceExposureType[] diff --git a/app/components/header/nav-bar/filter-panel.tsx b/app/components/header/nav-bar/filter-panel.tsx index e178f5f6..54edbf47 100644 --- a/app/components/header/nav-bar/filter-panel.tsx +++ b/app/components/header/nav-bar/filter-panel.tsx @@ -8,16 +8,13 @@ import { import { NavbarContext } from '.' import Spinner from '~/components/spinner' import { Button } from '~/components/ui/button' -import { - type DeviceExposureType, - type DeviceStatusType, -} from '~/db/schema/enum' import { type loader as exploreLoader } from '~/routes/explore' import FilterOptions from './filter-options/filter-options' import FilterTags from './filter-options/filter-tags' import FilterPhenomena from './filter-options/filter-phenomena' import FilterTime from './filter-options/filter-time' import { useTranslation } from 'react-i18next' +import { DeviceExposureType, DeviceStatusType } from '~/lib/device-enums' export type TimeFilterState = | { diff --git a/app/components/map/filter-visualization.tsx b/app/components/map/filter-visualization.tsx index 4772ac6e..77336652 100644 --- a/app/components/map/filter-visualization.tsx +++ b/app/components/map/filter-visualization.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { Fragment, useEffect } from 'react' import { useLoaderData, useNavigate } from 'react-router' -import { DeviceExposureZodEnum, DeviceStatusZodEnum } from '~/db/schema/enum' +import { DeviceExposureZodEnum, DeviceStatusZodEnum } from '~/lib/device-enums' import { type loader } from '~/routes/explore' const FILTER_KEYS = new Set(['exposure', 'status', 'tags']) diff --git a/app/db/models/profile.server.ts b/app/db/models/profile.server.ts index 5aa06719..e3b5d8c3 100644 --- a/app/db/models/profile.server.ts +++ b/app/db/models/profile.server.ts @@ -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( diff --git a/app/db/schema/enum.ts b/app/db/schema/enum.ts index e561d436..9d63619c 100644 --- a/app/db/schema/enum.ts +++ b/app/db/schema/enum.ts @@ -1,28 +1,13 @@ import { pgEnum } from 'drizzle-orm/pg-core' -import { z } from 'zod' -// Enum for device exposure types -export const DeviceExposureEnum = pgEnum('exposure', [ - 'indoor', - 'outdoor', - 'mobile', - 'unknown', -]) - -// Zod schema for validating device exposure types -export const DeviceExposureZodEnum = z.enum(DeviceExposureEnum.enumValues) - -// Type inferred from the Zod schema for device exposure types -export type DeviceExposureType = z.infer - -// Enum for device status types -export const DeviceStatusEnum = pgEnum('status', ['active', 'inactive', 'old']) +import { + DEVICE_EXPOSURE_VALUES, + DEVICE_STATUS_VALUES, +} from '~/lib/device-enums' -// Zod schema for validating device status types -export const DeviceStatusZodEnum = z.enum(DeviceStatusEnum.enumValues) +export const DeviceExposureEnum = pgEnum('exposure', DEVICE_EXPOSURE_VALUES) -// Type inferred from the Zod schema for device status types -export type DeviceStatusType = z.infer +export const DeviceStatusEnum = pgEnum('status', DEVICE_STATUS_VALUES) // Enum for device model types export const DeviceModelEnum = pgEnum('model', [ diff --git a/app/hooks/use-autosave-fetcher.ts b/app/hooks/use-autosave-fetcher.ts new file mode 100644 index 00000000..e42a3ff1 --- /dev/null +++ b/app/hooks/use-autosave-fetcher.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useFetcher } from 'react-router' + +export type AutosaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error' +export const AUTOSAVE_DELAY_MS = 700 + +type UseAutosaveFetcherOptions = { + values: TValues + lastSavedValues: TValues + debounceMs?: number + enabled?: boolean + validate?: (values: TValues) => boolean + getPayload: (values: TValues) => Record + isSuccess: (data: TData) => boolean + getSavedValues?: (data: TData, submittedValues: TValues) => TValues + onSuccess?: (data: TData) => void + onError?: (data: TData) => void +} + +export function useAutosaveFetcher({ + values, + lastSavedValues, + debounceMs = AUTOSAVE_DELAY_MS, + enabled = true, + validate, + getPayload, + isSuccess, + getSavedValues, + onSuccess, + onError, +}: UseAutosaveFetcherOptions) { + const fetcher = useFetcher() + + const lastSavedRef = useRef(lastSavedValues) + const lastSubmittedRef = useRef(null) + const processedDataRef = useRef(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, + } +} diff --git a/app/lib/api-schemas/boxes-data-query-schema.ts b/app/lib/api-schemas/boxes-data-query-schema.ts index 403c680d..f85e7f9a 100644 --- a/app/lib/api-schemas/boxes-data-query-schema.ts +++ b/app/lib/api-schemas/boxes-data-query-schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { type DeviceExposureType } from '~/db/schema' +import { type DeviceExposureType } from '../device-enums' import { StandardResponse } from '~/lib/responses' export type BoxesDataColumn = diff --git a/app/lib/device-enums.ts b/app/lib/device-enums.ts new file mode 100644 index 00000000..112aa013 --- /dev/null +++ b/app/lib/device-enums.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' + +export const DEVICE_EXPOSURE_VALUES = [ + 'indoor', + 'outdoor', + 'mobile', + 'unknown', +] as const + +export const DEVICE_STATUS_VALUES = ['active', 'inactive', 'old'] as const + +export const DeviceExposureZodEnum = z.enum(DEVICE_EXPOSURE_VALUES) +export const DeviceStatusZodEnum = z.enum(DEVICE_STATUS_VALUES) + +export type DeviceExposureType = z.infer +export type DeviceStatusType = z.infer + +export function parseDeviceExposure(value: unknown): DeviceExposureType | null { + const normalized = typeof value === 'string' ? value.toLowerCase() : value + + const result = DeviceExposureZodEnum.safeParse(normalized) + + return result.success ? result.data : null +} + +export function getDeviceExposure(value: unknown): DeviceExposureType { + return parseDeviceExposure(value) ?? 'unknown' +} diff --git a/app/lib/device-general.ts b/app/lib/device-general.ts new file mode 100644 index 00000000..43a3920a --- /dev/null +++ b/app/lib/device-general.ts @@ -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 + +export type GeneralInfoErrors = { + form?: string + name?: string + description?: string + exposure?: string + temporaryExpirationDate?: string + tags?: string +} + + + diff --git a/app/lib/location.ts b/app/lib/location.ts index 367ee1bc..81460b67 100644 --- a/app/lib/location.ts +++ b/app/lib/location.ts @@ -1,10 +1,127 @@ -/** - * Checks whether the given longitude and latitude - * are within the value range - * @param lng Longitude - * @param lat Latitude - */ -export const validLngLat = (lng: number, lat: number): boolean => { - // the value range for lat is [-90, 90] and for longitude [-180, 180] - return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 +import { z } from 'zod' + +export const LOCATION_LIMITS = { + latitude: { + min: -90, + max: 90, + }, + longitude: { + min: -180, + max: 180, + }, +} as const + +const emptyStringToUndefined = (value: unknown) => { + if (typeof value === 'string' && value.trim() === '') { + return undefined + } + + return value +} + +export const locationSchema = z.object({ + latitude: z.preprocess( + emptyStringToUndefined, + z.coerce + .number({ + error: (issue) => + issue.input === undefined + ? 'Latitude is required' + : 'Latitude must be a valid number', + }) + .min( + LOCATION_LIMITS.latitude.min, + `Latitude must be greater than or equal to ${LOCATION_LIMITS.latitude.min}`, + ) + .max( + LOCATION_LIMITS.latitude.max, + `Latitude must be less than or equal to ${LOCATION_LIMITS.latitude.max}`, + ), + ), + + longitude: z.preprocess( + emptyStringToUndefined, + z.coerce + .number({ + error: (issue) => + issue.input === undefined + ? 'Longitude is required' + : 'Longitude must be a valid number', + }) + .min( + LOCATION_LIMITS.longitude.min, + `Longitude must be greater than or equal to ${LOCATION_LIMITS.longitude.min}`, + ) + .max( + LOCATION_LIMITS.longitude.max, + `Longitude must be less than or equal to ${LOCATION_LIMITS.longitude.max}`, + ), + ), +}) + +export type LocationData = z.infer + +export function validLngLat(lng: number, lat: number): boolean { + return locationSchema.safeParse({ + latitude: lat, + longitude: lng, + }).success +} + +export function isValidLocation(value: { + latitude: number | null | undefined + longitude: number | null | undefined +}): value is LocationData { + return locationSchema.safeParse(value).success +} + +export function parseLocationFormData(formData: FormData): + | { + success: true + data: LocationData + } + | { + success: false + errors: LocationFieldErrors + } { + const parsed = locationSchema.safeParse({ + latitude: formData.get('latitude'), + longitude: formData.get('longitude'), + }) + + if (parsed.success) { + return { + success: true, + data: parsed.data, + } + } + + return { + success: false, + errors: getLocationFieldErrors(parsed.error), + } +} + +export function getLocationFieldErrors(error: z.ZodError) { + const flattened = z.flattenError(error) + + return { + latitude: flattened.fieldErrors.latitude?.[0], + longitude: flattened.fieldErrors.longitude?.[0], + } +} + +export type LocationFieldErrors = { + latitude?: string + longitude?: string +} + +export function validateLocationFieldErrors(value: unknown): LocationFieldErrors { + const parsed = locationSchema.safeParse(value) + + if (parsed.success) { + return {} + } + + return getLocationFieldErrors(parsed.error) } diff --git a/app/routes/device.$deviceId.edit.general.tsx b/app/routes/device.$deviceId.edit.general.tsx index f3e07169..eef0d4c5 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -1,5 +1,5 @@ import { Save, Upload, X } from 'lucide-react' -import React, { useState } from 'react' +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { data, @@ -7,6 +7,7 @@ import { Form, useActionData, useLoaderData, + useNavigation, useOutletContext, } from 'react-router' import invariant from 'tiny-invariant' @@ -23,6 +24,79 @@ import { } from '~/lib/s3.server' import { updateDevice, deleteDevice } from '~/services/device-service.server' import { getUserEmail, getUserId } from '~/services/session-service.server' +import { + useAutosaveFetcher, + AUTOSAVE_DELAY_MS, +} from '~/hooks/use-autosave-fetcher' +import { + DeviceExposureType, + DeviceExposureZodEnum, + getDeviceExposure, + parseDeviceExposure, +} from '~/lib/device-enums' +import { AutosaveStatusText } from '~/components/autosave-status.text' + +type GeneralAutosaveValues = { + name: string + exposure: DeviceExposureType + description: string + website: string + tags: string[] +} + +type DeviceGeneralActionErrors = { + name?: string | null + exposure: string | null + passwordDelete: string | null + image: string | null +} + +export type DeviceGeneralActionData = + | { + intent: 'autosave-general' + success: true + device: GeneralAutosaveValues + errors: null + status: 200 + } + | { + intent: 'autosave-general' + success: false + message: string + errors: DeviceGeneralActionErrors + status: number + } + | { + intent: 'saveImage' | 'removeImage' + errors: DeviceGeneralActionErrors + status: number + } + | { + intent: 'delete' + errors: DeviceGeneralActionErrors + status: number + } + +function parseGroupTag(value: FormDataEntryValue | null): string[] { + if (typeof value !== 'string') return [] + + try { + const parsed = JSON.parse(value) + + if (!Array.isArray(parsed)) return [] + + return parsed + .filter((tag): tag is string => typeof tag === 'string') + .map((tag) => tag.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +function uniqueTags(tags: string[]) { + return Array.from(new Set(tags)) +} //***************************************************** export async function loader({ request, params }: Route.LoaderArgs) { @@ -34,9 +108,17 @@ export async function loader({ request, params }: Route.LoaderArgs) { const deviceData = await getDeviceWithoutSensors({ id: deviceID }) + if (!deviceData) { + throw new Response('Device not found', { status: 404 }) + } + + if (deviceData.userId !== userId) { + throw new Response('Forbidden', { status: 403 }) + } + let imageUrl: string | null = null - if (deviceData?.image) { + if (deviceData.image) { try { imageUrl = await getDeviceImageUrl(deviceData.image) } catch (error) { @@ -54,187 +136,299 @@ export async function loader({ request, params }: Route.LoaderArgs) { export async function action({ request, params }: Route.ActionArgs) { const deviceID = params.deviceId const userId = await getUserId(request) + invariant(typeof deviceID === 'string', 'Device id not found.') invariant(typeof userId === 'string', 'User id not found.') - const device = (await getDevice({ id: deviceID })) as Device + const device = (await getDevice({ id: deviceID })) as Device | null + + if (!device) { + throw new Response('Device not found', { status: 404 }) + } + + if (device.userId !== userId) { + throw new Response('Forbidden', { status: 403 }) + } const formData = await request.formData() + const intent = String(formData.get('intent') ?? '') - const { intent, name, exposure, description, website, passwordDelete } = - Object.fromEntries(formData) + switch (intent) { + case 'autosave-general': { + const name = String(formData.get('name') ?? '') + const exposure = parseDeviceExposure(formData.get('exposure')) + const description = String(formData.get('description') ?? '') + const website = String(formData.get('website') ?? '') + const tags = uniqueTags(parseGroupTag(formData.get('grouptag'))) + + if (!name.trim()) { + return data( + { + intent, + success: false, + message: 'Device name is required.', + errors: { + name: 'Device name is required.', + exposure: null, + passwordDelete: null, + image: null, + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - const image = formData.get('image') as File | null + if (!exposure) { + return data( + { + intent, + success: false, + message: 'Invalid exposure.', + errors: { + name: null, + exposure: 'Invalid exposure.', + passwordDelete: null, + image: null, + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - const rawGroupTag = formData.get('grouptag') + if (description.length > 5000) { + return data( + { + intent, + success: false, + message: 'Description is too long.', + errors: { + name: null, + exposure: null, + passwordDelete: null, + image: null, + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - let grouptag: string[] | undefined - if (typeof rawGroupTag === 'string') { - try { - grouptag = JSON.parse(rawGroupTag) - } catch { - grouptag = [] + const result = await updateDevice(userId, device, { + name, + exposure, + description, + website, + grouptag: tags, + }) + + if (result === 'unauthorized') { + throw new Response('Forbidden', { status: 403 }) + } + + return data( + { + intent, + success: true, + device: { + name, + exposure, + description, + website, + tags, + }, + errors: null, + status: 200, + } satisfies DeviceGeneralActionData, + { status: 200 }, + ) } - } - const exposureLowerCase = - typeof exposure === 'string' ? exposure.toLowerCase() : '' + case 'saveImage': { + const image = formData.get('image') as File | null - const errors = { - exposure: exposure ? null : 'Invalid exposure.', - passwordDelete: passwordDelete ? null : 'Password is required.', - image: null as string | null, - } + if (!image || image.size === 0 || image.name === '') { + return data( + { + intent, + errors: { + name: null, + exposure: null, + passwordDelete: null, + image: 'Please choose an image to upload.', + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - invariant(typeof deviceID === 'string', 'Device id not found.') - invariant(typeof name === 'string', 'Device name is required.') - invariant(typeof exposure === 'string', 'Device exposure is required.') - invariant( - typeof description === 'string', - 'Device description must be a string.', - ) - invariant(typeof website === 'string', 'Device website must be a string.') - - if ( - exposureLowerCase !== 'indoor' && - exposureLowerCase !== 'outdoor' && - exposureLowerCase !== 'mobile' && - exposureLowerCase !== 'unknown' - ) { - return data({ - errors: { - exposure: 'Invalid exposure.', - passwordDelete: errors.passwordDelete, - image: null, - }, - status: 400, - }) - } + const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] + const maxSize = 5 * 1024 * 1024 - switch (intent) { - case 'save': { - let imageKey: string | undefined - - if (image && image.size > 0 && image.name !== '') { - const validTypes = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/gif', - ] - const maxSize = 5 * 1024 * 1024 // 5MB - - if (!validTypes.includes(image.type)) { - return data({ + if (!validTypes.includes(image.type)) { + return data( + { + intent, errors: { + name: null, exposure: null, passwordDelete: null, image: 'Invalid file type. Please upload a JPEG, PNG, WebP, or GIF.', }, status: 400, - }) - } + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - if (image.size > maxSize) { - return data({ + if (image.size > maxSize) { + return data( + { + intent, errors: { + name: null, exposure: null, passwordDelete: null, image: 'File too large. Maximum size is 5MB.', }, status: 400, - }) - } + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } - try { - imageKey = await uploadDeviceImage(deviceID, image) - } catch (error) { - console.error('Image upload error:', error) - return data({ + let imageKey: string + + try { + imageKey = await uploadDeviceImage(deviceID, image) + } catch (error) { + console.error('Image upload error:', error) + + return data( + { + intent, errors: { + name: null, exposure: null, passwordDelete: null, image: 'Failed to upload image. Please try again.', }, status: 500, - }) - } + } satisfies DeviceGeneralActionData, + { status: 500 }, + ) } const result = await updateDevice(userId, device, { - name: String(name), - exposure: exposureLowerCase, - description: String(description), - website: String(website), - grouptag, - ...(imageKey && { image: imageKey }), + image: imageKey, }) - if (result === 'unauthorized') + if (result === 'unauthorized') { throw new Response('Forbidden', { status: 403 }) + } - return data({ - errors: { exposure: null, passwordDelete: null, image: null }, - status: 200, - }) + return data( + { + intent, + errors: { + name: null, + exposure: null, + passwordDelete: null, + image: null, + }, + status: 200, + } satisfies DeviceGeneralActionData, + { status: 200 }, + ) } + case 'removeImage': { - const device = (await getDeviceWithoutSensors({ id: deviceID })) as Device + const deviceWithoutSensors = (await getDeviceWithoutSensors({ + id: deviceID, + })) as Device | null - if (device?.image) { + if (deviceWithoutSensors?.image) { try { - await deleteDeviceImage(device.image) + await deleteDeviceImage(deviceWithoutSensors.image) } catch (error) { console.error('Failed to delete image:', error) } } - await updateDevice(userId, device, { + const result = await updateDevice(userId, device, { image: '', }) - return data({ - errors: { - exposure: null, - passwordDelete: null, - image: null, - }, - status: 200, - }) + if (result === 'unauthorized') { + throw new Response('Forbidden', { status: 403 }) + } + + return data( + { + intent, + errors: { + name: null, + exposure: null, + passwordDelete: null, + image: null, + }, + status: 200, + } satisfies DeviceGeneralActionData, + { status: 200 }, + ) } + case 'delete': { - if (errors.passwordDelete) { - return data({ - errors, - status: 400, - }) + const passwordDelete = String(formData.get('passwordDelete') ?? '') + + if (!passwordDelete) { + return data( + { + intent, + errors: { + name: null, + exposure: null, + passwordDelete: 'Password is required.', + image: null, + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) } const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') - invariant(typeof passwordDelete === 'string', 'password must be a string') const user = await verifyLogin(userEmail, passwordDelete) if (!user) { return data( { + intent, errors: { + name: null, exposure: null, passwordDelete: 'Invalid password', image: null, }, - }, + status: 400, + } satisfies DeviceGeneralActionData, { status: 400 }, ) } - // Delete device image before deleting device - const device = (await getDeviceWithoutSensors({ id: deviceID })) as Device - if (device?.image) { + const deviceWithoutSensors = (await getDeviceWithoutSensors({ + id: deviceID, + })) as Device | null + + if (deviceWithoutSensors?.image) { try { - await deleteDeviceImage(device.image) + await deleteDeviceImage(deviceWithoutSensors.image) } catch (error) { console.error('Failed to delete device image:', error) } @@ -244,24 +438,65 @@ export async function action({ request, params }: Route.ActionArgs) { return redirect('/profile/me') } - } - return redirect('') + default: { + return data( + { + intent: 'autosave-general', + success: false, + message: 'Invalid intent.', + errors: { + name: null, + exposure: null, + passwordDelete: null, + image: null, + }, + status: 400, + } satisfies DeviceGeneralActionData, + { status: 400 }, + ) + } + } } //********************************** -export default function () { +export default function EditDeviceGeneral() { const { device, imageUrl } = useLoaderData() const actionData = useActionData() + const navigation = useNavigation() + const [passwordDelVal, setPasswordVal] = useState('') - const nameRef = React.useRef(null) - const passwordDelRef = React.useRef(null) - const [name, setName] = useState(device?.name) - const [exposure, setExposure] = useState(device?.exposure) - const [description, setDescription] = useState(device?.description || '') - const [tags, setTags] = useState(device?.tags ?? []) + const passwordDelRef = useRef(null) + const imageInputRef = useRef(null) + + const initialAutosaveValues = useMemo( + () => ({ + name: device.name ?? '', + exposure: getDeviceExposure(device.exposure), + description: device.description ?? '', + website: device.website ?? '', + tags: device.tags ?? [], + }), + [ + device.name, + device.exposure, + device.description, + device.website, + device.tags, + ], + ) + + const [name, setName] = useState(initialAutosaveValues.name) + const [exposure, setExposure] = useState( + initialAutosaveValues.exposure, + ) + const [description, setDescription] = useState( + initialAutosaveValues.description, + ) + const [tags, setTags] = useState(initialAutosaveValues.tags) const [newTag, setNewTag] = useState('') - const [website, setWebsite] = useState(device?.website || '') + const [website, setWebsite] = useState(initialAutosaveValues.website) + const { t } = useTranslation('edit-device-general') const [imagePreview, setImagePreview] = useState( @@ -270,16 +505,104 @@ export default function () { const [imageFile, setImageFile] = useState(null) const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() + const autosaveValues = useMemo( + () => ({ + name, + exposure, + description, + website, + tags, + }), + [name, exposure, description, website, tags], + ) + + const validateAutosave = useCallback((values: GeneralAutosaveValues) => { + return ( + values.name.trim().length > 0 && + DeviceExposureZodEnum.safeParse(values.exposure).success && + values.description.length <= 5000 + ) + }, []) + + const getAutosavePayload = useCallback((values: GeneralAutosaveValues) => { + return { + intent: 'autosave-general', + name: values.name, + exposure: values.exposure, + description: values.description, + website: values.website, + grouptag: JSON.stringify(values.tags), + } + }, []) + + const isAutosaveSuccess = useCallback( + (actionData: DeviceGeneralActionData) => { + return actionData.intent === 'autosave-general' && actionData.success + }, + [], + ) + + const getSavedValues = useCallback( + ( + actionData: DeviceGeneralActionData, + submittedValues: GeneralAutosaveValues, + ): GeneralAutosaveValues => { + if (actionData.intent !== 'autosave-general' || !actionData.success) { + return submittedValues + } + + return actionData.device + }, + [], + ) + + const handleAutosaveSuccess = useCallback(() => { + // Keep this quiet if you only want the inline "saved" text. + // Uncomment if you want the same toast behavior as the old save button: + // setToastOpen(true) + }, []) + + const handleAutosaveError = useCallback( + (actionData: DeviceGeneralActionData) => { + if (actionData.intent !== 'autosave-general') return + if (actionData.success) return + + console.warn(actionData.message) + }, + [], + ) + + const autosave = useAutosaveFetcher< + GeneralAutosaveValues, + DeviceGeneralActionData + >({ + values: autosaveValues, + lastSavedValues: initialAutosaveValues, + debounceMs: AUTOSAVE_DELAY_MS, + validate: validateAutosave, + getPayload: getAutosavePayload, + isSuccess: isAutosaveSuccess, + getSavedValues, + onSuccess: handleAutosaveSuccess, + onError: handleAutosaveError, + }) + + useEffect(() => { + if (imageFile) return + + setImagePreview(imageUrl || null) + }, [imageUrl, imageFile]) + const addTag = () => { const trimmed = newTag.trim() if (!trimmed || tags.includes(trimmed)) return - setTags([...tags, trimmed]) + setTags((currentTags) => [...currentTags, trimmed]) setNewTag('') } const removeTag = (tagToRemove: string) => { - setTags(tags.filter((tag) => tag !== tagToRemove)) + setTags((currentTags) => currentTags.filter((tag) => tag !== tagToRemove)) } function MarkdownPreview({ value }: { value?: string | null }) { @@ -300,144 +623,185 @@ export default function () { const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] - if (file) { - setImageFile(file) - const reader = new FileReader() - reader.onloadend = () => { - setImagePreview(reader.result as string) - } - reader.readAsDataURL(file) + + if (!file) return + + setImageFile(file) + + const reader = new FileReader() + + reader.onloadend = () => { + setImagePreview(reader.result as string) } + + reader.readAsDataURL(file) } - const handleRemoveImage = () => { + const handleRemoveImagePreview = () => { setImageFile(null) - setImagePreview(null) + setImagePreview(imageUrl || null) + + if (imageInputRef.current) { + imageInputRef.current.value = '' + } } - React.useEffect(() => { - if (actionData) { - const hasErrors = Object.values(actionData?.errors).some( - (errorMessage) => errorMessage, - ) + useEffect(() => { + if (!actionData) return + if (!('errors' in actionData)) return + if (!actionData.errors) return + + const actionErrors = actionData.errors + + const hasErrors = Object.values(actionErrors).some( + (errorMessage) => errorMessage, + ) + + if (!hasErrors) { + if (actionData.intent === 'saveImage') { + setImageFile(null) - if (!hasErrors) { - setToastOpen(true) - } else if (hasErrors && actionData?.errors?.passwordDelete) { - passwordDelRef.current?.focus() + if (imageInputRef.current) { + imageInputRef.current.value = '' + } + } + + if (actionData.intent === 'removeImage') { + setImageFile(null) + setImagePreview(null) + + if (imageInputRef.current) { + imageInputRef.current.value = '' + } } + + setToastOpen(true) + return + } + + if (actionErrors.passwordDelete) { + passwordDelRef.current?.focus() } }, [actionData, setToastOpen]) - const hasChanges = - name !== device?.name || - exposure !== device?.exposure || - description !== device?.description || - website !== device?.website || - imageFile !== device?.image || - JSON.stringify(tags) !== JSON.stringify(device?.tags ?? []) + const submittingIntent = navigation.formData?.get('intent') + const isImageSubmitting = + navigation.state === 'submitting' && + (submittingIntent === 'saveImage' || submittingIntent === 'removeImage') + const isDeleteSubmitting = + navigation.state === 'submitting' && submittingIntent === 'delete' + + const actionErrors = + actionData && 'errors' in actionData && actionData.errors + ? actionData.errors + : null + + const imageError = actionErrors?.image ?? null + const passwordDeleteError = actionErrors?.passwordDelete ?? null + + const hasNameError = name.trim().length === 0 + const hasDescriptionError = description.length > 5000 + const hasClientErrors = hasNameError || hasDescriptionError return (
-
-
-
-
-

{t('general')}

-
-
- -
+
+
+
+

{t('general')}

+ +
+
-
+
-
- {/* Name */} -
- -
- setName(e.target.value)} - ref={nameRef} - aria-describedby="name-error" - className="w-full rounded border border-gray-200 px-2 py-1 text-base" - /> -
+
+ {/* Name */} +
+ +
+ setName(e.target.value)} + aria-describedby="name-error" + className={ + 'w-full rounded border border-gray-200 px-2 py-1 text-base' + + (hasNameError + ? ' border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow-sm focus:shadow-[#FF0000]' + : '') + } + /> + + {hasNameError ? ( +

+ {t('name_is_required')} +

+ ) : null}
+
- {/* Exposure */} -
-
+
- {/* Description */} -
- + {/* Description */} +
+ -
-
-