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/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/hooks/use-autosave-fetcher.ts b/app/hooks/use-autosave-fetcher.ts new file mode 100644 index 00000000..b4a48cc8 --- /dev/null +++ b/app/hooks/use-autosave-fetcher.ts @@ -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 = { + 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 = 700, + 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/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 c2e06880..f17adcf8 100644 --- a/app/routes/device.$deviceId.edit.general.tsx +++ b/app/routes/device.$deviceId.edit.general.tsx @@ -446,7 +446,7 @@ Installed on the school roof. - Updated regularly [Project website](https://example.com)`} - className="min-h-[220px] w-full rounded border border-gray-200 px-2 py-1.5 font-mono text-base" + className="min-h-55 w-full rounded border border-gray-200 px-2 py-1.5 font-mono text-base" />

{description.length} / 5000 diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 5032355e..614a2950 100644 --- a/app/routes/device.$deviceId.edit.location.tsx +++ b/app/routes/device.$deviceId.edit.location.tsx @@ -1,4 +1,3 @@ -import { Save } from 'lucide-react' import React, { useCallback, useState } from 'react' import { type MarkerDragEvent, @@ -6,13 +5,7 @@ import { Marker, NavigationControl, } from 'react-map-gl/maplibre' -import { - redirect, - Form, - useActionData, - useLoaderData, - useOutletContext, -} from 'react-router' +import { data, redirect, useFetcher, useLoaderData } from 'react-router' import invariant from 'tiny-invariant' import { type Route } from './+types/device.$deviceId.edit.location' @@ -22,221 +15,364 @@ import { } from '~/db/models/device.server' import { getUserId } from '~/services/session-service.server' import { BaseMap } from '~/components/base-map' +import { + LOCATION_LIMITS, + LocationFieldErrors, + isValidLocation, + parseLocationFormData, + validateLocationFieldErrors, + type LocationData, +} from '~/lib/location' +import { useTranslation } from 'react-i18next' +import { AUTOSAVE_DELAY_MS } from './device.$deviceId.edit' + + +function isSameLocation(a: LocationData, b: LocationData) { + return a.latitude === b.latitude && a.longitude === b.longitude +} + +function parseNumberInput(value: string): number | null { + if (value.trim() === '') return null + + const parsed = Number(value) + + if (!Number.isFinite(parsed)) return null + + return parsed +} + +type MarkerValue = { + latitude: number | null + longitude: number | null +} //***************************************************** export async function loader({ request, params }: Route.LoaderArgs) { - //* if user is not logged in, redirect to home const userId = await getUserId(request) if (!userId) return redirect('/') const deviceID = params.deviceId + invariant(typeof deviceID === 'string', 'Device id not found.') - if (typeof deviceID !== 'string') { - return redirect('/profile/me') + const deviceData = await getDeviceWithoutSensors({ id: deviceID }) + + if (!deviceData) { + throw new Response('Device not found', { status: 404 }) } - const deviceData = await getDeviceWithoutSensors({ id: deviceID }) + if (deviceData.userId !== userId) { + throw new Response('Forbidden', { status: 403 }) + } return { device: deviceData } } //***************************************************** export async function action({ request, params }: Route.ActionArgs) { - const formData = await request.formData() - const { latitude, longitude } = Object.fromEntries(formData) + const userId = await getUserId(request) + if (!userId) return redirect('/') const id = params.deviceId - invariant(id, `deviceID not found!`) + invariant(typeof id === 'string', 'Device id not found.') + + const device = await getDeviceWithoutSensors({ id }) + + 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 parsed = parseLocationFormData(formData) + + if (!parsed.success) { + return data( + { + ok: false as const, + errors: parsed.errors, + }, + { status: 400 }, + ) + } await updateDeviceLocation({ - id: id, - latitude: Number(latitude), - longitude: Number(longitude), + id, + latitude: parsed.data.latitude, + longitude: parsed.data.longitude, }) - return { isUpdated: true } + return data({ + ok: true as const, + location: parsed.data, + errors: null, + savedAt: new Date().toISOString(), + }) } //********************************** export default function EditLocation() { const { device } = useLoaderData() - const actionData = useActionData() - //* map marker - const [marker, setMarker] = useState({ - latitude: device?.latitude, - longitude: device?.longitude, + const fetcher = useFetcher() + const { t } = useTranslation("edit-device-general") + + const initialLocation = React.useMemo( + () => ({ + latitude: device.latitude, + longitude: device.longitude, + }), + [device.latitude, device.longitude], + ) + + const [marker, setMarker] = useState(initialLocation) + const [hasSavedOnce, setHasSavedOnce] = React.useState(false) + const [lastSavedLocation, setLastSavedLocation] = + useState(initialLocation) + + const currentLocation = React.useMemo(() => { + const candidate = { + latitude: marker.latitude, + longitude: marker.longitude, + } + + return isValidLocation(candidate) ? candidate : null + }, [marker.latitude, marker.longitude]) + + const originalLocationRef = React.useRef({ + latitude: device.latitude, + longitude: device.longitude, }) - //* on-marker-drag event + + const originalLocation = originalLocationRef.current + + const hasUnsavedChanges = + currentLocation !== null && + !isSameLocation(currentLocation, lastSavedLocation) + + const isSaving = fetcher.state !== 'idle' + + const clientErrors = validateLocationFieldErrors(marker) + + const serverErrors: LocationFieldErrors = + fetcher.data?.ok === false ? fetcher.data.errors : {} + + const locationErrors = { + latitude: clientErrors.latitude ?? serverErrors.latitude, + longitude: clientErrors.longitude ?? serverErrors.longitude, + } + + + const saveLocation = React.useCallback( + (location: LocationData) => { + fetcher.submit( + { + latitude: String(location.latitude), + longitude: String(location.longitude), + }, + { method: 'post' }, + ) + }, + [fetcher], + ) + + React.useEffect(() => { + if (!currentLocation) return + if (!hasUnsavedChanges) return + + const timeout = window.setTimeout(() => { + saveLocation(currentLocation) + }, AUTOSAVE_DELAY_MS) + + return () => window.clearTimeout(timeout) + }, [currentLocation, hasUnsavedChanges, saveLocation]) + + React.useEffect(() => { + if (fetcher.state !== 'idle') return + if (!fetcher.data) return + + if (fetcher.data.ok) { + setLastSavedLocation(fetcher.data.location) + setHasSavedOnce(true) + } + }, [fetcher.state, fetcher.data]) + const onMarkerDrag = useCallback((event: MarkerDragEvent) => { setMarker({ longitude: event.lngLat.lng, latitude: event.lngLat.lat, }) }, []) - //* to view toast on edit-page - const [setToastOpen] = useOutletContext<[(_open: boolean) => void]>() - React.useEffect(() => { - //* if sensors data were updated successfully - if (actionData && actionData?.isUpdated) { - //* show notification when data is successfully updated - setToastOpen(true) - } - }, [actionData, setToastOpen]) + const onLatitudeChange = (event: React.ChangeEvent) => { + const latitude = parseNumberInput(event.target.value) + + setMarker((current) => ({ + ...current, + latitude, + })) + } + + const onLongitudeChange = (event: React.ChangeEvent) => { + const longitude = parseNumberInput(event.target.value) + + setMarker((current) => ({ + ...current, + longitude, + })) + } + + const resetToOriginalLocation = () => { + setMarker({ ...originalLocation }) + } + + const mapLocation = currentLocation ?? lastSavedLocation return (

- {/* location form */}
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

Location

-
-
- {/* Save button */} - +
+
+
+

{t('exposure')}

+ +
+
+ {isSaving ? ( +

{t('saving')}

+ ) : fetcher.data?.ok === false ? ( +

{t('autosave_failed')}

+ ) : hasUnsavedChanges ? ( +

{t('unsaved_changes')}

+ ) : hasSavedOnce ? ( +

{t('saved')}

+ ) : null} +
+
- {/* divider */} -
- - {/* Map view */} -
- - +
+ +
+ + + {currentLocation ? ( - - - -
+ /> + ) : null} - {/* Latitude, Longitude btns */} -
-
-
- - -
- { - const value = Number(e.target.value) - if (value >= -85.06 && value <= 85.06) { - setMarker({ - latitude: value, - longitude: marker.longitude, - }) - } - }} - aria-describedby="name-error" - className={ - 'w-full rounded border border-gray-200 px-2 py-1 text-base' + - (!marker.latitude - ? ' border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow-sm focus:shadow-[#FF0000]' - : '') - } - /> -
-
+ + + +
-
- - -
- { - const value = Number(e.target.value) - if (value >= -180 && value <= 180) { - setMarker({ - latitude: marker.latitude, - longitude: value, - }) - } - }} - aria-describedby="name-error" - className={ - 'w-full rounded border border-gray-200 px-2 py-1 text-base' + - (!marker.longitude - ? ' border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow-sm focus:shadow-[#FF0000]' - : '') - } - /> -
+
+
+
+ + +
+ + + {locationErrors.latitude ? ( +

+ {locationErrors.latitude} +

+ ) : null}
- +
+ + +
+ + + {locationErrors.longitude ? ( +

+ {locationErrors.longitude} +

+ ) : null} +
+
- + + +
) -} +} \ No newline at end of file diff --git a/app/routes/device.$deviceId.edit.tsx b/app/routes/device.$deviceId.edit.tsx index d641f69b..b33e9470 100644 --- a/app/routes/device.$deviceId.edit.tsx +++ b/app/routes/device.$deviceId.edit.tsx @@ -21,6 +21,9 @@ import { getIntegrations } from '~/db/models/integration.server' import { getLucideIcon } from '~/lib/lucide-icon-map' import { getUserId } from '~/services/session-service.server' +export const AUTOSAVE_DELAY_MS = 700 + + //***************************************************** export async function loader({ request }: Route.LoaderArgs) { //* if user is not logged in, redirect to home diff --git a/app/routes/settings.profile.tsx b/app/routes/settings.profile.tsx index 59a1a10a..6edbcd88 100644 --- a/app/routes/settings.profile.tsx +++ b/app/routes/settings.profile.tsx @@ -1,8 +1,9 @@ import { CopyIcon, CopyCheckIcon, InfoIcon } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Form, Link, Outlet, useActionData, useLoaderData } from 'react-router' +import { Link, Outlet, useLoaderData } from 'react-router' import { type Route } from './+types/settings.profile' + import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar' import { Button } from '~/components/ui/button' import { @@ -23,11 +24,13 @@ import { TooltipTrigger, } from '~/components/ui/tooltip' import { useToast } from '~/components/ui/use-toast' + import { getProfileByUserId, updateProfile } from '~/db/models/profile.server' -import { getInitials } from '~/lib/strings' -import { requireUserId } from '~/services/session-service.server' import { getUserById } from '~/db/models/user.server' import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard' +import { useAutosaveFetcher } from '~/hooks/use-autosave-fetcher' +import { getInitials } from '~/lib/strings' +import { requireUserId } from '~/services/session-service.server' export async function loader({ request }: Route.LoaderArgs) { const userId = await requireUserId(request) @@ -56,45 +59,189 @@ export async function loader({ request }: Route.LoaderArgs) { } } -export async function action({ request }: Route.ActionArgs) { +export type ProfileActionData = + | { + intent: 'autosave-profile' + success: true + updatedProfile: { + id: string + displayName: string + public: boolean + userId: string + } + } + | { + intent: 'autosave-profile' + success: false + message: string + } + +export async function action({ + request, +}: Route.ActionArgs): Promise { const userId = await requireUserId(request) const profile = await getProfileByUserId(userId) - const formData = await request.formData() - const displayName = formData.get('displayName') - const isPublic = formData.get('isPublic') if (!profile || !userId) { return { + intent: 'autosave-profile', success: false, message: 'Something went wrong.', } } - const updatedProfile = await updateProfile( - profile.id, - displayName as string, - isPublic === 'on', - ) + const formData = await request.formData() + + const intent = String(formData.get('intent') ?? '') + const displayName = String(formData.get('displayName') ?? '').trim() + const isPublic = formData.get('isPublic') === 'true' + + if (intent !== 'autosave-profile') { + return { + intent: 'autosave-profile', + success: false, + message: 'Invalid intent.', + } + } + + if (displayName.length < 3 || displayName.length > 40) { + return { + intent: 'autosave-profile', + success: false, + message: 'Display name must be between 3 and 40 characters.', + } + } + + const updatedProfile = await updateProfile(profile.id, displayName, isPublic) + + if (!updatedProfile) { + return { + intent: 'autosave-profile', + success: false, + message: 'Profile could not be updated.', + } + } return { + intent: 'autosave-profile', success: true, - updatedProfile, + updatedProfile: { + id: updatedProfile.id, + displayName: updatedProfile.displayName, + public: updatedProfile.public ?? false, + userId: updatedProfile.userId, + }, } } +type ProfileAutosaveValues = { + displayName: string + isPublic: boolean +} + export default function EditUserProfilePage() { const data = useLoaderData() - const actionData = useActionData() const [displayName, setDisplayName] = useState(data.profile.displayName) - const [isPublic, setIsPublic] = useState(data.profile.public || false) + const [isPublic, setIsPublic] = useState(data.profile.public ?? false) const { t } = useTranslation('settings') + const { toast } = useToast() const publicProfileUrl = data.publicProfileUrl - const { copiedToClipboard, copyToClipboard } = useCopyToClipboard() + const validateAutosave = useCallback((values: ProfileAutosaveValues) => { + const nextDisplayName = values.displayName.trim() + + return nextDisplayName.length >= 3 && nextDisplayName.length <= 40 + }, []) + + const getAutosavePayload = useCallback((values: ProfileAutosaveValues) => { + return { + intent: 'autosave-profile', + displayName: values.displayName.trim(), + isPublic: String(values.isPublic), + } + }, []) + + const isAutosaveSuccess = useCallback((actionData: ProfileActionData) => { + return actionData.intent === 'autosave-profile' && actionData.success + }, []) + + const getSavedValues = useCallback( + ( + actionData: ProfileActionData, + submittedValues: ProfileAutosaveValues, + ): ProfileAutosaveValues => { + if (!actionData.success) return submittedValues + + return { + displayName: actionData.updatedProfile.displayName, + isPublic: actionData.updatedProfile.public, + } + }, + [], + ) + + const handleAutosaveError = useCallback( + (actionData: ProfileActionData) => { + if (actionData.success) return + + toast({ + title: t('something_went_wrong'), + description: actionData.message, + variant: 'destructive', + }) + }, + [toast, t], + ) + + const autosave = useAutosaveFetcher( + { + values: { + displayName, + isPublic, + }, + lastSavedValues: { + displayName: data.profile.displayName, + isPublic: data.profile.public ?? false, + }, + debounceMs: 700, + validate: validateAutosave, + getPayload: getAutosavePayload, + isSuccess: isAutosaveSuccess, + getSavedValues, + onError: handleAutosaveError, + }, + ) + + useEffect(() => { + setDisplayName(data.profile.displayName) + setIsPublic(data.profile.public ?? false) + + autosave.resetLastSaved({ + displayName: data.profile.displayName, + isPublic: data.profile.public ?? false, + }) + }, [data.profile.displayName, data.profile.public, autosave.resetLastSaved]) + + const handlePublicChange = useCallback( + (checked: boolean) => { + setIsPublic(checked) + + const nextValues = { + displayName, + isPublic: checked, + } + + if (validateAutosave(nextValues)) { + autosave.submit(nextValues) + } + }, + [autosave, displayName, validateAutosave], + ) + const handleCopyPublicProfileUrl = async () => { const copied = await copyToClipboard(publicProfileUrl) @@ -106,156 +253,145 @@ export default function EditUserProfilePage() { }) } - //* toast - const { toast } = useToast() + return ( + + + {t('profile_settings')} + {t('profile_settings_description')} + - useEffect(() => { - if (actionData) { - if (actionData.success) { - toast({ - title: t('profile_updated'), - description: t('profile_updated_description'), - variant: 'success', - }) - } else { - toast({ - title: t('something_went_wrong'), - description: t('something_went_wrong_description'), - variant: 'destructive', - }) - } - } - }, [actionData, toast, t]) + +
+
+
+ - return ( -
- - - {t('profile_settings')} - {t('profile_settings_description')} - - -
-
-
- - - - - - - -

{t('if_public')}

-
-
-
-
- setDisplayName(e.target.value)} - /> + + + + + + +

{t('if_public')}

+
+
+
-
-
- - - - - - - -

- {t('if_activated_public_1')}{' '} - - - {t('if_activated_public_2')} - - - {t('if_activated_public_3')} -

-
-
-
-
- - {isPublic && ( -
- - -
- event.target.select()} - /> - - -
-
- )} -
+ setDisplayName(event.target.value)} + />
-
-
- - - - {getInitials(data.profile?.displayName ?? '')} - - - - ✎ - + +
+
+ + + + + + + + +

+ {t('if_activated_public_1')}{' '} + + + {t('if_activated_public_2')} + + + {t('if_activated_public_3')} +

+
+
+
+ + + + {isPublic && ( +
+ + +
+ event.target.select()} + /> + + +
+
+ )} +
+
+ +
+
+ + + + {getInitials(data.profile?.displayName ?? '')} + + + + + ✎ +
- - - - - - - +
+ + + +

+ {autosave.status === 'saving' + ? t('saving') + : autosave.status === 'dirty' + ? t('unsaved_changes') + : autosave.status === 'error' + ? t('something_went_wrong') + : autosave.status === 'saved' + ? t('saved') + : null} +

+
+ + + ) } diff --git a/public/locales/de/edit-device-general.json b/public/locales/de/edit-device-general.json index a259c4ae..6a469c10 100644 --- a/public/locales/de/edit-device-general.json +++ b/public/locales/de/edit-device-general.json @@ -15,5 +15,12 @@ "accepted_formats": "Akzeptierte Formate", "add": "Hinzufügen", "delete_device": "Gerät löschen", - "delete_device_confirm_info": "Wenn Sie Ihre Station wirklich löschen möchten, geben Sie bitte Ihr aktuelles Passwort ein – dabei werden auch alle Messwerte gelöscht." + "delete_device_confirm_info": "Wenn Sie Ihre Station wirklich löschen möchten, geben Sie bitte Ihr aktuelles Passwort ein – dabei werden auch alle Messwerte gelöscht.", + "saving": "Speichern...", + "saved": "Gespeichert", + "autosave_failed": "Automatisches Speichern fehlgeschlagen. Bitte versuche es erneut.", + "unsaved_changes": "Ungesicherte Änderungen", + "longitude": "Längengrad", + "latitude": "Breitengrad", + "reset_to_original_location": "Zurücksetzen auf ursprünglichen Standort" } diff --git a/public/locales/de/settings.json b/public/locales/de/settings.json index 64a4295f..4fd37dea 100644 --- a/public/locales/de/settings.json +++ b/public/locales/de/settings.json @@ -26,8 +26,8 @@ "change": "Ändern", "invalid_password": "Falsches Passwort", "profile_successfully_updated": "Profil efolgreich aktualisiert", - "account_information": "Konto Informationen", - "update_basic_details": "Aktualisiere deine grundlegenden Konto Details.", + "account_information": "Kontoinformationen", + "update_basic_details": "Aktualisiere deine grundlegenden Kontodetails.", "name": "Name", "enter_name": "Gib deinen Namen ein", "email": "E-Mail", diff --git a/public/locales/en/edit-device-general.json b/public/locales/en/edit-device-general.json index 2911dc38..6f5f1c86 100644 --- a/public/locales/en/edit-device-general.json +++ b/public/locales/en/edit-device-general.json @@ -15,5 +15,12 @@ "accepted_formats": "Accepted formats", "add": "Add", "delete_device": "Delete device", - "delete_device_confirm_info": "If you really want to delete your station, please type your current password - all measurements will be deleted as well." + "delete_device_confirm_info": "If you really want to delete your station, please type your current password - all measurements will be deleted as well.", + "saving": "Saving...", + "saved": "Saved", + "autosave_failed": "Automatic saving failed. Please try again.", + "unsaved_changes": "Unsaved changes", + "longitude": "Longitude", + "latitude": "Latitude", + "reset_to_original_location": "Reset to original location" }