-
+ {/* Description */}
+
+
-
- {/* Website */}
-
-
-
- setWebsite(e.target.value)}
- className="w-full rounded border border-gray-200 px-2 py-1 text-base"
- />
-
+ {/* Website */}
+
+
+
+ setWebsite(e.target.value)}
+ className="w-full rounded border border-gray-200 px-2 py-1 text-base"
+ />
+
+
+
+ {/* Tags */}
+
+
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+
+ ))}
- {/* Image Upload */}
+
+ setNewTag(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ addTag()
+ }
+ }}
+ placeholder="Add a tag"
+ className="flex-1 rounded border border-gray-200 px-2 py-1 text-base"
+ />
+
+
+
+
+ {/* Image Upload */}
+
diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx
index 5032355e..d9aef63a 100644
--- a/app/routes/device.$deviceId.edit.location.tsx
+++ b/app/routes/device.$deviceId.edit.location.tsx
@@ -1,18 +1,11 @@
-import { Save } from 'lucide-react'
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useMemo, useRef, useState } from 'react'
import {
type MarkerDragEvent,
MapProvider,
Marker,
NavigationControl,
} from 'react-map-gl/maplibre'
-import {
- redirect,
- Form,
- useActionData,
- useLoaderData,
- useOutletContext,
-} from 'react-router'
+import { data, redirect, useLoaderData } from 'react-router'
import invariant from 'tiny-invariant'
import { type Route } from './+types/device.$deviceId.edit.location'
@@ -22,219 +15,409 @@ import {
} from '~/db/models/device.server'
import { getUserId } from '~/services/session-service.server'
import { BaseMap } from '~/components/base-map'
+import {
+ LOCATION_LIMITS,
+ isValidLocation,
+ parseLocationFormData,
+ validateLocationFieldErrors,
+ type LocationData,
+ type LocationFieldErrors,
+} from '~/lib/location'
+import { useTranslation } from 'react-i18next'
+import {
+ useAutosaveFetcher,
+ AUTOSAVE_DELAY_MS,
+} from '~/hooks/use-autosave-fetcher'
+import { AutosaveStatusText } from '~/components/autosave-status.text'
+
+function parseNumberInput(value: string): number | null {
+ if (value.trim() === '') return null
+
+ const parsed = Number(value)
+
+ if (!Number.isFinite(parsed)) return null
+
+ return parsed
+}
+
+function normalizeCoordinate(value: number | null) {
+ if (value === null) return null
+
+ return Number(value.toFixed(6))
+}
+
+function normalizeLocationValues(values: LocationAutosaveValues) {
+ return {
+ latitude: normalizeCoordinate(values.latitude),
+ longitude: normalizeCoordinate(values.longitude),
+ }
+}
+
+type MarkerValue = {
+ latitude: number | null
+ longitude: number | null
+}
+
+export type LocationActionData =
+ | {
+ ok: true
+ location: LocationData
+ errors: null
+ savedAt: string
+ }
+ | {
+ ok: false
+ errors: LocationFieldErrors
+ }
+
+type LocationAutosaveValues = {
+ 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 { t } = useTranslation('edit-device-general')
+
+ const initialLocation = useMemo(
+ () => ({
+ latitude: device.latitude,
+ longitude: device.longitude,
+ }),
+ [device.latitude, device.longitude],
+ )
+
+ const [marker, setMarker] = useState(initialLocation)
+
+ const currentLocation = useMemo(() => {
+ const candidate = {
+ latitude: marker.latitude,
+ longitude: marker.longitude,
+ }
+
+ return isValidLocation(candidate) ? candidate : null
+ }, [marker.latitude, marker.longitude])
+
+ const originalLocationRef = useRef({
+ latitude: device.latitude,
+ longitude: device.longitude,
})
- //* on-marker-drag event
+
+ const originalLocation = originalLocationRef.current
+
+ const validateAutosave = useCallback((values: LocationAutosaveValues) => {
+ return isValidLocation(values)
+ }, [])
+
+ const getAutosavePayload = useCallback((values: LocationAutosaveValues) => {
+ return {
+ latitude: String(values.latitude),
+ longitude: String(values.longitude),
+ }
+ }, [])
+
+ const isAutosaveSuccess = useCallback((actionData: LocationActionData) => {
+ return actionData.ok
+ }, [])
+
+ const getSavedValues = useCallback(
+ (
+ actionData: LocationActionData,
+ submittedValues: LocationAutosaveValues,
+ ): LocationAutosaveValues => {
+ if (!actionData.ok) return submittedValues
+
+ return normalizeLocationValues(submittedValues)
+ },
+ [],
+ )
+
+ const autosaveValues = useMemo(
+ () =>
+ normalizeLocationValues({
+ latitude: marker.latitude,
+ longitude: marker.longitude,
+ }),
+ [marker.latitude, marker.longitude],
+ )
+
+ const initialAutosaveValues = useMemo(
+ () => normalizeLocationValues(initialLocation),
+ [initialLocation],
+ )
+
+ const autosave = useAutosaveFetcher<
+ LocationAutosaveValues,
+ LocationActionData
+ >({
+ values: autosaveValues,
+ lastSavedValues: initialAutosaveValues,
+ debounceMs: AUTOSAVE_DELAY_MS,
+ validate: validateAutosave,
+ getPayload: getAutosavePayload,
+ isSuccess: isAutosaveSuccess,
+ getSavedValues,
+ })
+
+ const clientErrors = validateLocationFieldErrors(marker)
+
+ const serverErrors: LocationFieldErrors =
+ autosave.status === 'error' && autosave.fetcher.data?.ok === false
+ ? autosave.fetcher.data.errors
+ : {}
+
+ const locationErrors = {
+ latitude: clientErrors.latitude ?? serverErrors.latitude,
+ longitude: clientErrors.longitude ?? serverErrors.longitude,
+ }
+
+ const hasClientErrors = Boolean(
+ clientErrors.latitude || clientErrors.longitude,
+ )
+
+ const lastSavedLocation = autosave.lastSavedRef.current
+
+ const mapLocation = currentLocation ?? {
+ latitude: lastSavedLocation.latitude ?? initialLocation.latitude,
+ longitude: lastSavedLocation.longitude ?? initialLocation.longitude,
+ }
+
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 })
+ }
return (
- {/* location form */}
- {/* Form */}
-
- {/* Heading */}
-
- {/* Title */}
-
-
-
Location
-
-
- {/* Save button */}
-
-
+
- {/* 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}
+
+
-
+
+
+
diff --git a/app/routes/settings.account.tsx b/app/routes/settings.account.tsx
index fd00f351..2ca626ef 100644
--- a/app/routes/settings.account.tsx
+++ b/app/routes/settings.account.tsx
@@ -9,6 +9,8 @@ import {
data,
redirect,
useSearchParams,
+ useSubmit,
+ useNavigation,
} from 'react-router'
import invariant from 'tiny-invariant'
import { type Route } from './+types/settings.account'
@@ -39,9 +41,19 @@ import {
updateUserlocale,
verifyLogin,
getUserByAnyEmail,
+ updateUserPassword,
} from '~/db/models/user.server'
import { getUserId } from '~/services/session-service.server'
import { resendEmailConfirmation } from '~/services/user-service.server'
+import { validatePassLength, validatePassType } from '~/utils'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '~/components/ui/dialog'
export async function loader({ request }: Route.LoaderArgs) {
const userId = await getUserId(request)
@@ -63,6 +75,68 @@ export async function action({ request }: Route.ActionArgs) {
const user = await getUserById(userId)
if (!user) return redirect('/')
+ if (intent === 'update-password') {
+ const currPass = String(formData.get('currentPassword') ?? '')
+ const newPass = String(formData.get('newPassword') ?? '')
+ const confirmPass = String(formData.get('newPasswordConfirm') ?? '')
+ const passwordsList = [currPass, newPass, confirmPass]
+
+ const checkPasswordsType = validatePassType(passwordsList)
+ if (!checkPasswordsType.isValid) {
+ return data(
+ {
+ intent,
+ success: false,
+ message: 'Password is required.',
+ },
+ { status: 400 },
+ )
+ }
+
+ const validatePasswordsLength = validatePassLength(passwordsList)
+ if (!validatePasswordsLength.isValid) {
+ return data(
+ {
+ intent,
+ success: false,
+ message: 'Password must be at least 8 characters long.',
+ },
+ { status: 400 },
+ )
+ }
+
+ if (newPass !== confirmPass) {
+ return data(
+ {
+ intent,
+ success: false,
+ message: 'New passwords do not match.',
+ },
+ { status: 400 },
+ )
+ }
+
+ const ok = await verifyLogin(user.email, currPass)
+ if (!ok) {
+ return data(
+ {
+ intent,
+ success: false,
+ message: 'Current password is incorrect.',
+ },
+ { status: 400 },
+ )
+ }
+
+ await updateUserPassword(userId, newPass)
+
+ return data({
+ intent,
+ success: true,
+ message: 'Password updated successfully.',
+ })
+ }
+
if (intent === 'resend-verification') {
try {
const result = await resendEmailConfirmation(user)
@@ -198,7 +272,8 @@ export async function action({ request }: Route.ActionArgs) {
export default function EditUserProfilePage() {
const userData = useLoaderData
()
const actionData = useActionData()
- const fetcher = useFetcher()
+ const resendFetcher = useFetcher()
+ const passwordFetcher = useFetcher()
const { toast } = useToast()
const { t } = useTranslation('settings')
@@ -213,8 +288,15 @@ export default function EditUserProfilePage() {
toast({ title: t('verification_link_invalid'), variant: 'destructive' })
}, [params, toast, t])
+ const submit = useSubmit()
+ const navigation = useNavigation()
+
+ const profileFormRef = useRef(null)
const passwordUpdRef = useRef(null)
+ const [emailConfirmOpen, setEmailConfirmOpen] = useState(false)
+ const [emailPassword, setEmailPassword] = useState('')
+
const { pendingEmail, hasPendingEmail, emailShown, showConfirmed } =
useMemo(() => {
const pending = (userData?.unconfirmedEmail ?? '').trim()
@@ -233,6 +315,36 @@ export default function EditUserProfilePage() {
const [email, setEmail] = useState(emailShown)
const [lang, setLang] = useState(userData?.language ?? 'en_US')
+ const emailChanged = email.trim() !== emailShown.trim()
+
+ function submitProfileWithPassword() {
+ if (!profileFormRef.current) return
+
+ const formData = new FormData(profileFormRef.current)
+
+ formData.set('intent', 'update-profile')
+ formData.set('passwordUpdate', emailPassword)
+
+ submit(formData, { method: 'post' })
+ }
+
+ function handleSaveClick(event: React.MouseEvent) {
+ if (!emailChanged) return
+
+ event.preventDefault()
+ setEmailConfirmOpen(true)
+
+ window.requestAnimationFrame(() => {
+ passwordUpdRef.current?.focus()
+ })
+ }
+ const [passwordDialogOpen, setPasswordDialogOpen] = useState(false)
+
+ const passwordFormRef = useRef(null)
+ const currPassRef = useRef(null)
+ const newPassRef = useRef(null)
+ const confirmPassRef = useRef(null)
+
useEffect(() => {
setName(userData?.name ?? '')
setLang(userData?.language ?? 'en_US')
@@ -245,7 +357,12 @@ export default function EditUserProfilePage() {
if (actionData.errors?.passwordUpdate) {
toast({ title: t('invalid_password'), variant: 'destructive' })
- passwordUpdRef.current?.focus()
+ setEmailConfirmOpen(true)
+
+ window.requestAnimationFrame(() => {
+ passwordUpdRef.current?.focus()
+ })
+
return
}
@@ -262,15 +379,39 @@ export default function EditUserProfilePage() {
return
}
+ setEmailConfirmOpen(false)
+ setEmailPassword('')
+
toast({ title: t('profile_successfully_updated'), variant: 'success' })
}, [actionData, toast, t])
useEffect(() => {
- if (fetcher.state !== 'idle' || !fetcher.data) return
- if (fetcher.data.intent !== 'resend-verification') return
- if (!('code' in fetcher.data)) return
+ if (passwordFetcher.state !== 'idle' || !passwordFetcher.data) return
+ if (passwordFetcher.data.intent !== 'update-password') return
+ if (!('success' in passwordFetcher.data)) return
+
+ if (passwordFetcher.data.success) {
+ passwordFormRef.current?.reset()
+ toast({ title: passwordFetcher.data.message, variant: 'success' })
+ setPasswordDialogOpen(false)
+ return
+ }
+
+ toast({
+ title: passwordFetcher.data.message,
+ variant: 'destructive',
+ description: t('try_again'),
+ })
+
+ currPassRef.current?.focus()
+ }, [passwordFetcher.state, passwordFetcher.data, toast, t])
+
+ useEffect(() => {
+ if (resendFetcher.state !== 'idle' || !resendFetcher.data) return
+ if (resendFetcher.data.intent !== 'resend-verification') return
+ if (!('code' in resendFetcher.data)) return
- const { code } = fetcher.data
+ const { code } = resendFetcher.data
if (code === 'Ok') {
toast({ title: t('verification_email_sent'), variant: 'success' })
} else if (code === 'UnprocessableContent') {
@@ -278,7 +419,7 @@ export default function EditUserProfilePage() {
} else {
toast({ title: t('verification_email_failed'), variant: 'destructive' })
}
- }, [fetcher.state, fetcher.data, toast, t])
+ }, [resendFetcher.state, resendFetcher.data, toast, t])
const saveDisabled =
name === (userData?.name ?? '') &&
@@ -286,125 +427,275 @@ export default function EditUserProfilePage() {
email.trim() === emailShown.trim()
return (
-
-
-
- {t('account_information')}
- {t('update_basic_details')}
-
-
-
-
- setName(e.target.value)}
- />
- {name !== (userData?.name ?? '') && (
-
- , code: }}
- />
-
- )}
-
-
-
-
-
setEmail(e.target.value)}
- />
-
- {showConfirmed ? (
-
-
- {t('email_confirmed')}
-
-
- ) : (
-
-
+ <>
+
+
+
+ {t('account_information')}
+ {t('update_basic_details')}
+
+
+
+
+ setName(e.target.value)}
+ />
+ {name !== (userData?.name ?? '') && (
+
+ , code: }}
+ />
+
+ )}
+
+
+
+
+
setEmail(e.target.value)}
+ />
+
+ {showConfirmed ? (
+
- {' '}
- {hasPendingEmail
- ? t('email_not_confirmed')
- : t('email_not_confirmed')}
+ {t('email_confirmed')}
-
-
+ ) : (
+
+
+
+ {' '}
+ {hasPendingEmail
+ ? t('email_not_confirmed')
+ : t('email_not_confirmed')}
+
+
+
+
+
+ )}
+
+ {hasPendingEmail ? (
+
+ {t('email_change_pending_hint', {
+ pendingEmail,
+ currentEmail: userData?.email ?? '',
+ })}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
{t('update_password')}
+
+ {t('update_password_description')}
+
- )}
-
- {hasPendingEmail ? (
-
- {t('email_change_pending_hint', {
- pendingEmail,
- currentEmail: userData?.email ?? '',
- })}
-
- ) : null}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/app/routes/settings.password.tsx b/app/routes/settings.password.tsx
deleted file mode 100644
index 8f61ddb7..00000000
--- a/app/routes/settings.password.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import { useEffect, useRef } from 'react'
-import { useTranslation } from 'react-i18next'
-import { data, redirect, Form, useActionData } from 'react-router'
-import invariant from 'tiny-invariant'
-import { type Route } from './+types/settings.password'
-import { useToast } from '@/components/ui/use-toast'
-import { Button } from '~/components/ui/button'
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '~/components/ui/card'
-import { Input } from '~/components/ui/input'
-import { Label } from '~/components/ui/label'
-import { updateUserPassword, verifyLogin } from '~/db/models/user.server'
-import { getUserEmail, getUserId } from '~/services/session-service.server'
-import { validatePassLength, validatePassType } from '~/utils'
-
-//*****************************************************
-export async function loader({ request }: Route.LoaderArgs) {
- //* if user is not logged in, redirect to home
- const userId = await getUserId(request)
- if (!userId) return redirect('/')
- return {}
-}
-
-//*****************************************************
-export async function action({ request }: Route.ActionArgs) {
- const formData = await request.formData()
- const intent = formData.get('intent')
- const currPass = formData.get('currentPassword')
- const newPass = formData.get('newPassword')
- const confirmPass = formData.get('newPasswordConfirm')
- const passwordsList = [currPass, newPass, confirmPass]
-
- //* when cancel button is clicked
- if (intent === 'cancel') {
- return redirect('/settings/account')
- }
-
- //* validate passwords type
- const checkPasswordsType = validatePassType(passwordsList)
- if (!checkPasswordsType.isValid) {
- return data(
- {
- success: false,
- message: 'Password is required.',
- },
- { status: 400 },
- )
- }
-
- //* validate passwords lenghts
- const validatePasswordsLength = validatePassLength(passwordsList)
- if (!validatePasswordsLength.isValid) {
- return data(
- {
- success: false,
- message: 'Password must be at least 8 characters long.',
- },
- { status: 400 },
- )
- }
-
- //* get user email
- const userEmail = await getUserEmail(request)
- invariant(userEmail, `Email not found!`)
-
- //* validate password
- if (typeof currPass !== 'string' || currPass.length === 0) {
- return data(
- {
- success: false,
- message: 'Current password is required.',
- },
- { status: 400 },
- )
- }
-
- //* check both new passwords match
- if (newPass !== confirmPass) {
- return data(
- {
- success: false,
- message: 'New passwords do not match.',
- },
- { status: 400 },
- )
- }
-
- //* check user password is correct
- const user = await verifyLogin(userEmail, currPass)
-
- if (!user) {
- return data(
- { success: false, message: 'Current password is incorrect.' },
- { status: 400 },
- )
- }
-
- //* get user ID
- const userId = await getUserId(request)
- invariant(userId, `userId not found!`)
-
- if (typeof newPass !== 'string' || newPass.length === 0) {
- return data(
- { success: false, message: 'Password is required.' },
- { status: 400 },
- )
- }
-
- //* update user password
- await updateUserPassword(userId, newPass)
-
- return data({ success: true, message: 'Password updated successfully.' })
- //* logout
- // return logout({ request: request, redirectTo: "/explore" });
-}
-
-//**********************************
-export default function ChangePaasswordPage() {
- const actionData = useActionData
()
-
- let $form = useRef(null)
- const currPassRef = useRef(null)
- const newPassRef = useRef(null)
- const confirmPassRef = useRef(null)
-
- //* toast
- const { toast } = useToast()
- const { t } = useTranslation('settings')
-
- useEffect(() => {
- if (actionData) {
- $form.current?.reset()
- if (actionData.success) {
- toast({ title: actionData.message, variant: 'success' })
- currPassRef.current?.focus()
- } else {
- toast({
- title: actionData.message,
- variant: 'destructive',
- description: t('try_again'),
- })
- }
- }
- }, [actionData, toast])
-
- return (
-
-
-
- {t('update_password')}
- {t('update_password_description')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/app/routes/settings.profile.tsx b/app/routes/settings.profile.tsx
index 59a1a10a..ac8f0007 100644
--- a/app/routes/settings.profile.tsx
+++ b/app/routes/settings.profile.tsx
@@ -1,15 +1,15 @@
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 {
Card,
CardContent,
CardDescription,
- CardFooter,
CardHeader,
CardTitle,
} from '~/components/ui/card'
@@ -23,11 +23,17 @@ 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 {
+ AUTOSAVE_DELAY_MS,
+ useAutosaveFetcher,
+} from '~/hooks/use-autosave-fetcher'
+import { getInitials } from '~/lib/strings'
+import { requireUserId } from '~/services/session-service.server'
+import { AutosaveStatusText } from '~/components/autosave-status.text'
export async function loader({ request }: Route.LoaderArgs) {
const userId = await requireUserId(request)
@@ -56,45 +62,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: AUTOSAVE_DELAY_MS,
+ 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 +256,132 @@ 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 ?? '')}
+
+
+
+
+ ✎
+
+
+
+
+
+
+
)
}
diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx
index d2ccd232..dec9adfa 100644
--- a/app/routes/settings.tsx
+++ b/app/routes/settings.tsx
@@ -33,12 +33,6 @@ export default function SettingsLayoutPage() {
>
{t('account')}
-
- {t('password')}
-