From ecb7e4f1a1ef85d4b6cddbc20d8efee4755f6f36 Mon Sep 17 00:00:00 2001 From: jona159 Date: Fri, 8 May 2026 08:03:58 +0200 Subject: [PATCH 01/10] feat: rework editing location --- app/routes/device.$deviceId.edit.location.tsx | 555 ++++++++++++------ 1 file changed, 388 insertions(+), 167 deletions(-) diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 5032355e..1353f363 100644 --- a/app/routes/device.$deviceId.edit.location.tsx +++ b/app/routes/device.$deviceId.edit.location.tsx @@ -6,13 +6,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' @@ -23,220 +17,447 @@ import { import { getUserId } from '~/services/session-service.server' import { BaseMap } from '~/components/base-map' +const LATITUDE_MIN = -85.06 +const LATITUDE_MAX = 85.06 +const LONGITUDE_MIN = -180 +const LONGITUDE_MAX = 180 +const AUTOSAVE_DELAY_MS = 700 + +type LocationValue = { + latitude: number + longitude: number +} + +type MarkerValue = { + latitude: number | null + longitude: number | null +} + +function isValidLatitude(value: number | null): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= LATITUDE_MIN && + value <= LATITUDE_MAX + ) +} + +function isValidLongitude(value: number | null): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= LONGITUDE_MIN && + value <= LONGITUDE_MAX + ) +} + +function isValidLocation(value: MarkerValue): value is LocationValue { + return isValidLatitude(value.latitude) && isValidLongitude(value.longitude) +} + +function isSameLocation(a: LocationValue, b: LocationValue) { + 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 +} + //***************************************************** 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.') + + const deviceData = await getDeviceWithoutSensors({ id: deviceID }) - if (typeof deviceID !== 'string') { - return redirect('/profile/me') + 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 intent = formData.get('intent') + + if (intent !== 'autosaveLocation' && intent !== 'saveLocation') { + return data( + { + ok: false as const, + intent, + errors: { + form: 'Invalid action.', + }, + }, + { status: 400 }, + ) + } + + const latitudeRaw = formData.get('latitude') + const longitudeRaw = formData.get('longitude') + + const latitude = + typeof latitudeRaw === 'string' ? Number(latitudeRaw) : Number.NaN + + const longitude = + typeof longitudeRaw === 'string' ? Number(longitudeRaw) : Number.NaN + + const errors: { + latitude?: string + longitude?: string + form?: string + } = {} + + if ( + !Number.isFinite(latitude) || + latitude < LATITUDE_MIN || + latitude > LATITUDE_MAX + ) { + errors.latitude = `Latitude must be between ${LATITUDE_MIN} and ${LATITUDE_MAX}.` + } + + if ( + !Number.isFinite(longitude) || + longitude < LONGITUDE_MIN || + longitude > LONGITUDE_MAX + ) { + errors.longitude = `Longitude must be between ${LONGITUDE_MIN} and ${LONGITUDE_MAX}.` + } + + if (Object.keys(errors).length > 0) { + return data( + { + ok: false as const, + intent, + errors, + }, + { status: 400 }, + ) + } await updateDeviceLocation({ - id: id, - latitude: Number(latitude), - longitude: Number(longitude), + id, + latitude, + longitude, }) - return { isUpdated: true } + return data({ + ok: true as const, + intent, + location: { + latitude, + longitude, + }, + 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, - }) - //* on-marker-drag event + const fetcher = useFetcher() + + const initialLocation = React.useMemo( + () => ({ + latitude: device.latitude, + longitude: device.longitude, + }), + [device.latitude, device.longitude], + ) + + const [marker, setMarker] = useState(initialLocation) + const [lastSavedLocation, setLastSavedLocation] = + useState(initialLocation) + + const currentLocation = React.useMemo(() => { + if (!isValidLocation(marker)) return null + + return { + latitude: marker.latitude, + longitude: marker.longitude, + } + }, [marker]) + + const hasUnsavedChanges = + currentLocation !== null && + !isSameLocation(currentLocation, lastSavedLocation) + + const isSaving = fetcher.state !== 'idle' + + const latitudeError = + marker.latitude === null + ? 'Latitude is required.' + : !isValidLatitude(marker.latitude) + ? `Latitude must be between ${LATITUDE_MIN} and ${LATITUDE_MAX}.` + : fetcher.data?.ok === false + // @ts-ignore + ? fetcher.data.errors.latitude + : null + + const longitudeError = + marker.longitude === null + ? 'Longitude is required.' + : !isValidLongitude(marker.longitude) + ? `Longitude must be between ${LONGITUDE_MIN} and ${LONGITUDE_MAX}.` + : fetcher.data?.ok === false + // @ts-ignore + ? fetcher.data.errors.longitude + : null + + const saveLocation = React.useCallback( + (location: LocationValue, intent: 'autosaveLocation' | 'saveLocation') => { + fetcher.submit( + { + intent, + 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, 'autosaveLocation') + }, 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) + } + }, [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 resetToSavedLocation = () => { + setMarker(lastSavedLocation) + } + + const mapLocation = currentLocation ?? lastSavedLocation return (
- {/* location form */}
- {/* Form */} -
- {/* Heading */} -
- {/* Title */} -
-
-

Location

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

Location

+ +
+ {isSaving ? ( +

Saving...

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

+ Autosave failed. Your changes are still local. +

+ ) : hasUnsavedChanges ? ( +

Unsaved changes

+ ) : ( +

Saved

+ )}
-
- {/* 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]' - : '') - } - /> -
+ {latitudeError ? ( +

+ {latitudeError} +

+ ) : null}
- +
+ + +
+ + + {longitudeError ? ( +

+ {longitudeError} +

+ ) : null} +
+
- + + +
) -} +} \ No newline at end of file From 83d15c9dd0c70c1e250b22d3719d6a34a48651db Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 09:57:01 +0200 Subject: [PATCH 02/10] feat: centralize location logic --- app/components/device/new/location-info.tsx | 41 ++++--- .../device/new/new-device-stepper.tsx | 23 +--- app/lib/location.ts | 100 ++++++++++++++++-- app/routes/device.$deviceId.edit.location.tsx | 20 ++-- 4 files changed, 132 insertions(+), 52 deletions(-) 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..96d4d9b4 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -29,6 +29,7 @@ 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' const generalInfoSchema = z.object({ name: z @@ -63,27 +64,6 @@ const generalInfoSchema = z.object({ .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'), -}) - const deviceSchema = z.object({ model: z.enum(DeviceModelEnum.enumValues, { error: () => 'Please select a device.', @@ -153,7 +133,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/lib/location.ts b/app/lib/location.ts index 367ee1bc..e954c3c8 100644 --- a/app/lib/location.ts +++ b/app/lib/location.ts @@ -1,10 +1,92 @@ -/** - * 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) { + return locationSchema.safeParse({ + latitude: formData.get('latitude'), + longitude: formData.get('longitude'), + }) +} + +export function getLocationFieldErrors(error: z.ZodError) { + const flattened = z.flattenError(error) + + return { + latitude: flattened.fieldErrors.latitude?.[0], + longitude: flattened.fieldErrors.longitude?.[0], + } } diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 1353f363..391f85a0 100644 --- a/app/routes/device.$deviceId.edit.location.tsx +++ b/app/routes/device.$deviceId.edit.location.tsx @@ -222,8 +222,8 @@ export default function EditLocation() { : !isValidLatitude(marker.latitude) ? `Latitude must be between ${LATITUDE_MIN} and ${LATITUDE_MAX}.` : fetcher.data?.ok === false - // @ts-ignore - ? fetcher.data.errors.latitude + ? // @ts-ignore + fetcher.data.errors.latitude : null const longitudeError = @@ -232,8 +232,8 @@ export default function EditLocation() { : !isValidLongitude(marker.longitude) ? `Longitude must be between ${LONGITUDE_MIN} and ${LONGITUDE_MAX}.` : fetcher.data?.ok === false - // @ts-ignore - ? fetcher.data.errors.longitude + ? // @ts-ignore + fetcher.data.errors.longitude : null const saveLocation = React.useCallback( @@ -404,7 +404,10 @@ export default function EditLocation() { /> {latitudeError ? ( -

+

{latitudeError}

) : null} @@ -439,7 +442,10 @@ export default function EditLocation() { /> {longitudeError ? ( -

+

{longitudeError}

) : null} @@ -460,4 +466,4 @@ export default function EditLocation() {
) -} \ No newline at end of file +} From 72174008b0cbbfcfdb6d73821b6666056be328ce Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 10:58:07 +0200 Subject: [PATCH 03/10] feat: improve edit location component, translations --- app/lib/location.ts | 39 ++- app/routes/device.$deviceId.edit.location.tsx | 263 ++++++------------ app/routes/device.$deviceId.edit.tsx | 3 + public/locales/de/edit-device-general.json | 9 +- public/locales/en/edit-device-general.json | 9 +- 5 files changed, 142 insertions(+), 181 deletions(-) diff --git a/app/lib/location.ts b/app/lib/location.ts index e954c3c8..81460b67 100644 --- a/app/lib/location.ts +++ b/app/lib/location.ts @@ -75,11 +75,31 @@ export function isValidLocation(value: { return locationSchema.safeParse(value).success } -export function parseLocationFormData(formData: FormData) { - return locationSchema.safeParse({ +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) { @@ -90,3 +110,18 @@ export function getLocationFieldErrors(error: z.ZodError) { 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.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 391f85a0..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, @@ -16,46 +15,19 @@ import { } from '~/db/models/device.server' import { getUserId } from '~/services/session-service.server' import { BaseMap } from '~/components/base-map' - -const LATITUDE_MIN = -85.06 -const LATITUDE_MAX = 85.06 -const LONGITUDE_MIN = -180 -const LONGITUDE_MAX = 180 -const AUTOSAVE_DELAY_MS = 700 - -type LocationValue = { - latitude: number - longitude: number -} - -type MarkerValue = { - latitude: number | null - longitude: number | null -} - -function isValidLatitude(value: number | null): value is number { - return ( - typeof value === 'number' && - Number.isFinite(value) && - value >= LATITUDE_MIN && - value <= LATITUDE_MAX - ) -} - -function isValidLongitude(value: number | null): value is number { - return ( - typeof value === 'number' && - Number.isFinite(value) && - value >= LONGITUDE_MIN && - value <= LONGITUDE_MAX - ) -} - -function isValidLocation(value: MarkerValue): value is LocationValue { - return isValidLatitude(value.latitude) && isValidLongitude(value.longitude) -} - -function isSameLocation(a: LocationValue, b: LocationValue) { +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 } @@ -69,6 +41,11 @@ function parseNumberInput(value: string): number | null { return parsed } +type MarkerValue = { + latitude: number | null + longitude: number | null +} + //***************************************************** export async function loader({ request, params }: Route.LoaderArgs) { const userId = await getUserId(request) @@ -109,58 +86,14 @@ export async function action({ request, params }: Route.ActionArgs) { } const formData = await request.formData() - const intent = formData.get('intent') - if (intent !== 'autosaveLocation' && intent !== 'saveLocation') { - return data( - { - ok: false as const, - intent, - errors: { - form: 'Invalid action.', - }, - }, - { status: 400 }, - ) - } - - const latitudeRaw = formData.get('latitude') - const longitudeRaw = formData.get('longitude') + const parsed = parseLocationFormData(formData) - const latitude = - typeof latitudeRaw === 'string' ? Number(latitudeRaw) : Number.NaN - - const longitude = - typeof longitudeRaw === 'string' ? Number(longitudeRaw) : Number.NaN - - const errors: { - latitude?: string - longitude?: string - form?: string - } = {} - - if ( - !Number.isFinite(latitude) || - latitude < LATITUDE_MIN || - latitude > LATITUDE_MAX - ) { - errors.latitude = `Latitude must be between ${LATITUDE_MIN} and ${LATITUDE_MAX}.` - } - - if ( - !Number.isFinite(longitude) || - longitude < LONGITUDE_MIN || - longitude > LONGITUDE_MAX - ) { - errors.longitude = `Longitude must be between ${LONGITUDE_MIN} and ${LONGITUDE_MAX}.` - } - - if (Object.keys(errors).length > 0) { + if (!parsed.success) { return data( { ok: false as const, - intent, - errors, + errors: parsed.errors, }, { status: 400 }, ) @@ -168,17 +101,13 @@ export async function action({ request, params }: Route.ActionArgs) { await updateDeviceLocation({ id, - latitude, - longitude, + latitude: parsed.data.latitude, + longitude: parsed.data.longitude, }) return data({ ok: true as const, - intent, - location: { - latitude, - longitude, - }, + location: parsed.data, errors: null, savedAt: new Date().toISOString(), }) @@ -188,8 +117,9 @@ export async function action({ request, params }: Route.ActionArgs) { export default function EditLocation() { const { device } = useLoaderData() const fetcher = useFetcher() + const { t } = useTranslation("edit-device-general") - const initialLocation = React.useMemo( + const initialLocation = React.useMemo( () => ({ latitude: device.latitude, longitude: device.longitude, @@ -198,17 +128,25 @@ export default function EditLocation() { ) const [marker, setMarker] = useState(initialLocation) + const [hasSavedOnce, setHasSavedOnce] = React.useState(false) const [lastSavedLocation, setLastSavedLocation] = - useState(initialLocation) - - const currentLocation = React.useMemo(() => { - if (!isValidLocation(marker)) return null + useState(initialLocation) - return { + const currentLocation = React.useMemo(() => { + const candidate = { latitude: marker.latitude, longitude: marker.longitude, } - }, [marker]) + + return isValidLocation(candidate) ? candidate : null + }, [marker.latitude, marker.longitude]) + + const originalLocationRef = React.useRef({ + latitude: device.latitude, + longitude: device.longitude, + }) + + const originalLocation = originalLocationRef.current const hasUnsavedChanges = currentLocation !== null && @@ -216,31 +154,21 @@ export default function EditLocation() { const isSaving = fetcher.state !== 'idle' - const latitudeError = - marker.latitude === null - ? 'Latitude is required.' - : !isValidLatitude(marker.latitude) - ? `Latitude must be between ${LATITUDE_MIN} and ${LATITUDE_MAX}.` - : fetcher.data?.ok === false - ? // @ts-ignore - fetcher.data.errors.latitude - : null - - const longitudeError = - marker.longitude === null - ? 'Longitude is required.' - : !isValidLongitude(marker.longitude) - ? `Longitude must be between ${LONGITUDE_MIN} and ${LONGITUDE_MAX}.` - : fetcher.data?.ok === false - ? // @ts-ignore - fetcher.data.errors.longitude - : null + 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: LocationValue, intent: 'autosaveLocation' | 'saveLocation') => { + (location: LocationData) => { fetcher.submit( { - intent, latitude: String(location.latitude), longitude: String(location.longitude), }, @@ -255,7 +183,7 @@ export default function EditLocation() { if (!hasUnsavedChanges) return const timeout = window.setTimeout(() => { - saveLocation(currentLocation, 'autosaveLocation') + saveLocation(currentLocation) }, AUTOSAVE_DELAY_MS) return () => window.clearTimeout(timeout) @@ -267,6 +195,7 @@ export default function EditLocation() { if (fetcher.data.ok) { setLastSavedLocation(fetcher.data.location) + setHasSavedOnce(true) } }, [fetcher.state, fetcher.data]) @@ -295,8 +224,8 @@ export default function EditLocation() { })) } - const resetToSavedLocation = () => { - setMarker(lastSavedLocation) + const resetToOriginalLocation = () => { + setMarker({ ...originalLocation }) } const mapLocation = currentLocation ?? lastSavedLocation @@ -308,37 +237,22 @@ export default function EditLocation() {
-

Location

+

{t('exposure')}

- {isSaving ? ( -

Saving...

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

- Autosave failed. Your changes are still local. -

- ) : hasUnsavedChanges ? ( -

Unsaved changes

- ) : ( -

Saved

- )} +
+ {isSaving ? ( +

{t('saving')}

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

{t('autosave_failed')}

+ ) : hasUnsavedChanges ? ( +

{t('unsaved_changes')}

+ ) : hasSavedOnce ? ( +

{t('saved')}

+ ) : null} +
- -
- -
@@ -380,7 +294,7 @@ export default function EditLocation() { htmlFor="latitude" className="txt-base block font-bold tracking-normal" > - Latitude + {t('latitude')}
@@ -390,25 +304,23 @@ export default function EditLocation() { autoFocus={true} name="latitude" type="number" - min={LATITUDE_MIN} - max={LATITUDE_MAX} + step="any" + min={LOCATION_LIMITS.latitude.min} + max={LOCATION_LIMITS.latitude.max} value={marker.latitude ?? ''} onChange={onLatitudeChange} aria-describedby="latitude-error" className={ 'w-full rounded border border-gray-200 px-2 py-1 text-base' + - (latitudeError + (locationErrors.latitude ? ' border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow-sm focus:shadow-[#FF0000]' : '') } /> - {latitudeError ? ( -

- {latitudeError} + {locationErrors.latitude ? ( +

+ {locationErrors.latitude}

) : null}
@@ -419,7 +331,7 @@ export default function EditLocation() { htmlFor="longitude" className="txt-base block font-bold tracking-normal" > - Longitude + {t('longitude')}
@@ -428,25 +340,23 @@ export default function EditLocation() { required name="longitude" type="number" - min={LONGITUDE_MIN} - max={LONGITUDE_MAX} + step="any" + min={LOCATION_LIMITS.longitude.min} + max={LOCATION_LIMITS.longitude.max} value={marker.longitude ?? ''} onChange={onLongitudeChange} aria-describedby="longitude-error" className={ 'w-full rounded border border-gray-200 px-2 py-1 text-base' + - (longitudeError + (locationErrors.longitude ? ' border-[#FF0000] shadow-[#FF0000] focus:border-[#FF0000] focus:shadow-sm focus:shadow-[#FF0000]' : '') } /> - {longitudeError ? ( -

- {longitudeError} + {locationErrors.longitude ? ( +

+ {locationErrors.longitude}

) : null}
@@ -455,15 +365,14 @@ export default function EditLocation() { ) -} +} \ 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/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/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" } From 3b9147f6ee4bd67eac10abe5f9fe9a67be64f50b Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 11:13:17 +0200 Subject: [PATCH 04/10] fix: tw warning --- app/routes/device.$deviceId.edit.general.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c16472b2fef77aef159abeabee220f69e5f9c808 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 12:53:16 +0200 Subject: [PATCH 05/10] feat: mv general device schema --- .../device/new/new-device-stepper.tsx | 35 +----------- app/lib/device-general.ts | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 app/lib/device-general.ts diff --git a/app/components/device/new/new-device-stepper.tsx b/app/components/device/new/new-device-stepper.tsx index 96d4d9b4..e2b99531 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -30,39 +30,7 @@ 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' - -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(), -}) +import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general' const deviceSchema = z.object({ model: z.enum(DeviceModelEnum.enumValues, { @@ -132,7 +100,6 @@ export const Stepper = defineStepper( }, ) -type GeneralInfoData = z.infer type DeviceData = z.infer type SensorData = z.infer type AdvancedData = z.infer 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 +} + + + From 693f5340533e5acc4f998f1806cd74b8c1641083 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 16:11:44 +0200 Subject: [PATCH 06/10] fix: tw warnings --- app/components/device/new/general-info.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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} ) : ( From 43c2b0e8f7924e3ac39906d03ac87914accf5896 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 11 May 2026 16:40:38 +0200 Subject: [PATCH 07/10] feat: return updated profile --- app/db/models/profile.server.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) 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( From c49b007f28ca042f604a9b57016a86595a80833e Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 12 May 2026 08:29:58 +0200 Subject: [PATCH 08/10] feat: autosave hook --- app/hooks/use-autosave-fetcher.ts | 119 ++++++++ app/routes/settings.profile.tsx | 458 +++++++++++++++++++----------- 2 files changed, 416 insertions(+), 161 deletions(-) create mode 100644 app/hooks/use-autosave-fetcher.ts diff --git a/app/hooks/use-autosave-fetcher.ts b/app/hooks/use-autosave-fetcher.ts new file mode 100644 index 00000000..7496cc82 --- /dev/null +++ b/app/hooks/use-autosave-fetcher.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useMemo, 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 [saveCount, setSaveCount] = useState(0) + const [hasError, setHasError] = useState(false) + + const valuesJson = useMemo(() => JSON.stringify(values), [values]) + const lastSavedJson = useMemo( + () => JSON.stringify(lastSavedRef.current), + [saveCount, valuesJson], + ) + + 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 (validate && !validate(values)) return + + const timeout = window.setTimeout(() => { + submit(values) + }, debounceMs) + + return () => window.clearTimeout(timeout) + }, [enabled, hasChanges, valuesJson, values, debounceMs, validate, submit]) + + useEffect(() => { + if (!fetcher.data) return + + const data = fetcher.data + + if (isSuccess(data)) { + const submittedValues = lastSubmittedRef.current ?? values + + lastSavedRef.current = getSavedValues + ? getSavedValues(data, submittedValues) + : submittedValues + + setHasError(false) + setSaveCount((count) => count + 1) + onSuccess?.(data) + } else { + setHasError(true) + onError?.(data) + } + }, [fetcher.data, getSavedValues, isSuccess, onSuccess, onError, values]) + + 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/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} +

+
+ + + ) } From 621f7e17d0de6a5b53962833de04e51e057f9dec Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 12 May 2026 08:30:08 +0200 Subject: [PATCH 09/10] fix: german translations --- public/locales/de/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", From 274226419867b5a500f7c099c0f2eb036543b1d5 Mon Sep 17 00:00:00 2001 From: jona159 Date: Tue, 12 May 2026 11:19:11 +0200 Subject: [PATCH 10/10] fix: avoid reprocessing old fetcher.data for unrelated state changes --- app/hooks/use-autosave-fetcher.ts | 42 +++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/app/hooks/use-autosave-fetcher.ts b/app/hooks/use-autosave-fetcher.ts index 7496cc82..b4a48cc8 100644 --- a/app/hooks/use-autosave-fetcher.ts +++ b/app/hooks/use-autosave-fetcher.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useFetcher } from 'react-router' export type AutosaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error' @@ -32,15 +32,13 @@ export function useAutosaveFetcher({ const lastSavedRef = useRef(lastSavedValues) const lastSubmittedRef = useRef(null) + const processedDataRef = useRef(null) const [saveCount, setSaveCount] = useState(0) const [hasError, setHasError] = useState(false) - const valuesJson = useMemo(() => JSON.stringify(values), [values]) - const lastSavedJson = useMemo( - () => JSON.stringify(lastSavedRef.current), - [saveCount, valuesJson], - ) + const valuesJson = JSON.stringify(values) + const lastSavedJson = JSON.stringify(lastSavedRef.current) const hasChanges = valuesJson !== lastSavedJson @@ -71,6 +69,7 @@ export function useAutosaveFetcher({ useEffect(() => { if (!enabled) return if (!hasChanges) return + if (isSaving) return if (validate && !validate(values)) return const timeout = window.setTimeout(() => { @@ -78,16 +77,30 @@ export function useAutosaveFetcher({ }, debounceMs) return () => window.clearTimeout(timeout) - }, [enabled, hasChanges, valuesJson, values, debounceMs, validate, submit]) + }, [ + enabled, + hasChanges, + isSaving, + valuesJson, + values, + debounceMs, + validate, + submit, + ]) useEffect(() => { - if (!fetcher.data) return + 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 (isSuccess(data)) { - const submittedValues = lastSubmittedRef.current ?? values + if (!submittedValues) return + if (isSuccess(data)) { lastSavedRef.current = getSavedValues ? getSavedValues(data, submittedValues) : submittedValues @@ -99,7 +112,14 @@ export function useAutosaveFetcher({ setHasError(true) onError?.(data) } - }, [fetcher.data, getSavedValues, isSuccess, onSuccess, onError, values]) + }, [ + fetcher.state, + fetcher.data, + getSavedValues, + isSuccess, + onSuccess, + onError, + ]) const resetLastSaved = useCallback((nextValues: TValues) => { lastSavedRef.current = nextValues