diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts new file mode 100644 index 000000000..32253ddf1 --- /dev/null +++ b/src/components/settings/account-section/account-section-utils.ts @@ -0,0 +1,58 @@ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +/** + * v1.4.37 — silent browser-zone auto-seed for the timezone picker. + * + * Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" / + * "Use browser timezone" button so the user could overwrite the + * Europe/Berlin seed with their detected zone on demand. The button + * was visually noisy next to the picker on mobile and almost every + * user wants the browser zone anyway, so the affordance retired and + * the bootstrap effect seeds the form for them. + * + * Rules: + * + * - If the stored value is anything other than the Europe/Berlin + * default, respect it. The user explicitly picked it. + * - If the stored value is the Europe/Berlin default but the + * browser actually IS in Berlin, leave it alone — the picker + * stays on Berlin and the next save is a no-op. + * - If the stored value is the default AND the browser reports a + * non-Berlin zone, pre-fill the picker with the detected zone. + * The form's existing submit handler persists the change on the + * next save; no toast, no banner, no opt-in. + * + * The bootstrap deliberately runs inline during render (the strict + * `react-hooks/set-state-in-effect` rule outlaws setState in an + * effect for this hydration shape), so this helper has to stay + * pure — no DOM access, no `useState`. The detected browser zone is + * passed in by the caller via `detectBrowserTimezone()`. + */ +export function resolveInitialTimezone( + storedTimezone: string | null | undefined, + detectedBrowserTimezone: string, +): string { + const stored = storedTimezone || DEFAULT_TIMEZONE; + const shouldAutoSeed = + stored === DEFAULT_TIMEZONE && + detectedBrowserTimezone.length > 0 && + detectedBrowserTimezone !== DEFAULT_TIMEZONE; + return shouldAutoSeed ? detectedBrowserTimezone : stored; +} + +/** + * v1.16.4 — settings status hints store the i18n KEY (+ params), not + * the translated string: a locale switch re-renders the hint in the + * new language instead of freezing the old-language snapshot. Server- + * provided error text (which has no key) rides `text` verbatim. + */ +export type StatusMessage = + | { key: string; params?: Record } + | { text: string }; + +export function statusText( + msg: StatusMessage, + t: (key: string, params?: Record) => string, +): string { + return "key" in msg ? t(msg.key, msg.params) : msg.text; +} diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx new file mode 100644 index 000000000..d3f48242b --- /dev/null +++ b/src/components/settings/account-section/avatar-section.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { ImageUp, Loader2, Trash2 } from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { statusText, type StatusMessage } from "./account-section-utils"; + +// Accepted upload types + byte cap mirror the server contract in +// `src/app/api/user/avatar/route.ts` so the client rejects an obvious +// bad file before the round-trip. The server still re-validates by +// magic-byte sniff — this is a UX shortcut, not the security boundary. +const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"]; +const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024; + +function getAvatarInitials(name: string): string { + return name + .split(/[\s._-]+/) + .slice(0, 2) + .map((s) => s[0]?.toUpperCase() ?? "") + .join(""); +} + +export function AvatarSection() { + const { t } = useTranslations(); + const { user, refetch } = useAuth(); + const fileInputRef = useRef(null); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const upload = useMutation({ + mutationFn: async (file: File) => { + const body = new FormData(); + body.append("file", file); + const res = await apiFetchRaw("/api/user/avatar", { + method: "POST", + body, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + if (res.status === 415) + throw new Error(t("settings.avatar.invalidType")); + if (res.status === 413) throw new Error(t("settings.avatar.tooLarge")); + throw new Error(json.error || t("settings.avatar.error")); + } + }, + onSuccess: async () => { + setMsgType("success"); + setMsg({ key: "settings.avatar.uploaded" }); + await refetch(); + }, + onError: (err: Error) => { + setMsgType("error"); + setMsg({ text: err.message }); + }, + }); + + const remove = useMutation({ + mutationFn: async () => { + const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error || t("settings.avatar.error")); + } + }, + onSuccess: async () => { + setMsgType("success"); + setMsg({ key: "settings.avatar.removed" }); + await refetch(); + }, + onError: (err: Error) => { + setMsgType("error"); + setMsg({ text: err.message }); + }, + }); + + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + // Reset so re-selecting the same file still fires `onChange`. + e.target.value = ""; + if (!file) return; + setMsg(null); + setMsgType(null); + if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { + setMsgType("error"); + setMsg({ key: "settings.avatar.invalidType" }); + return; + } + if (file.size > AVATAR_MAX_UPLOAD_BYTES) { + setMsgType("error"); + setMsg({ key: "settings.avatar.tooLarge" }); + return; + } + upload.mutate(file); + } + + if (!user) return null; + const avatarUrl = user.avatarUrl ?? null; + const busy = upload.isPending || remove.isPending; + + return ( + + +

+ {t("settings.avatar.description")} +

+
+ + {avatarUrl && } + + {getAvatarInitials(user.username)} + + +
+ + + {avatarUrl && ( + + )} +
+
+

+ {t("settings.avatar.hint")} +

+ {msg && ( +

+ {statusText(msg, t)} +

+ )} +
+ ); +} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section/index.tsx similarity index 56% rename from src/components/settings/account-section.tsx rename to src/components/settings/account-section/index.tsx index 4329df081..c6d917ae1 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section/index.tsx @@ -1,32 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertTriangle, - ImageUp, - KeyRound, - Loader2, - Save, - Shield, - Trash2, - User, -} from "lucide-react"; +import { KeyRound, Loader2, Save, Shield, User } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -42,11 +19,9 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; -import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { describePasskeyError } from "@/lib/passkey-errors"; -import { queryKeys } from "@/lib/query-keys"; import { AboutMeSection } from "@/components/settings/about-me-section"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -54,78 +29,17 @@ import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; -import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { detectBrowserTimezone } from "@/lib/tz/format"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + resolveInitialTimezone, + statusText, + type StatusMessage, +} from "./account-section-utils"; +import { PasskeyListSection } from "./passkey-list-section"; +import { AvatarSection } from "./avatar-section"; -interface PasskeyInfo { - id: string; - name: string; - credentialDeviceType: string; - credentialBackedUp: boolean; - createdAt: string; -} - -// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant -// retired; the shared `` primitive owns the visual -// contract now. Existing ` - - {avatarUrl && ( - - )} - - -

- {t("settings.avatar.hint")} -

- {msg && ( -

- {statusText(msg, t)} -

- )} - - ); -} diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx new file mode 100644 index 000000000..4144f1ef9 --- /dev/null +++ b/src/components/settings/account-section/passkey-list-section.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; + +interface PasskeyInfo { + id: string; + name: string; + credentialDeviceType: string; + credentialBackedUp: boolean; + createdAt: string; +} + +export function PasskeyListSection({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleteMsg, setDeleteMsg] = useState(null); + + const { data: passkeys } = useQuery({ + queryKey: queryKeys.passkeys(), + queryFn: async () => { + return apiGet("/api/auth/passkeys"); + }, + enabled: isAuthenticated, + }); + + const deletePasskey = useMutation({ + mutationFn: async (id: string) => { + await apiDelete(`/api/auth/passkeys/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); + setDeleteMsg(null); + }, + onError: (err: Error) => { + setDeleteMsg(err.message); + }, + }); + + const DEVICE_TYPE_LABELS: Record = { + singleDevice: t("settings.singleDevice"), + multiDevice: t("settings.multiDevice"), + }; + + if (!passkeys || passkeys.length === 0) { + return ( +
+

{t("settings.passkeys")}

+

+ {t("settings.noPasskeys")} +

+
+ ); + } + + return ( +
+

+ {t("settings.registeredPasskeys")} +

+

+ {t("settings.passkeysDescription")} +

+ {/* Phase A5 / B-mobile HIGH: the passkey table previously + rendered with `min-w-[620px]` inside `overflow-x-auto`, + which on a 393px viewport hid the right-most column — + including the destructive delete action. Render the desktop + table only at `≥ md`, and at `< md` paint a card-list where + every passkey's name, device type, backup status, created + date, and delete action are all visible without scrolling. */} + {/* v1.4.33 — desktop table flips to card list at `lg` instead + of `md` so iPad portrait (768 px = exactly the `md` + inflection) lands on the card layout. The table needs + ~620 px of column width to read; below that it scrolls + horizontally and the destructive delete column is the one + that disappears. */} +
+ + + + + + + + + + + + {passkeys.map((pk, idx) => ( + + + + + + + + ))} + +
+ {t("settings.passkeyName")} + + {t("settings.passkeyDevice")} + + {t("settings.passkeyBackup")} + + {t("settings.passkeyCreated")} + + {t("settings.passkeyActions")} +
{pk.name} + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + + {t("common.cancel")} + + deletePasskey.mutate(pk.id)} + > + {deletePasskey.isPending && ( + + )} + {t("common.delete")} + + + + +
+
+ + {/* Mobile + tablet card list — < lg only. Each passkey gets + its own card so the delete action stays visible and + tap-targetable (44 px) without the user having to + horizontally scroll through a wide table. v1.4.33 bumped + the breakpoint from `md` to `lg` so iPad portrait (768 px) + stays on the card layout instead of flipping between + layouts on rotation. */} +
    + {passkeys.map((pk) => ( +
  • +
    +
    +

    {pk.name}

    + {/* v1.4.27 MB7 / CF-75 — promote the device-type from + plain text to an outline Badge so the mobile card + list reads consistent with the desktop table's + "Single-device / Multi-device" column. Sits on the + same chip row as the backup status and date so all + metadata reads as a single horizontal stride. */} +
    + + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + +
    +
    + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + {t("common.cancel")} + deletePasskey.mutate(pk.id)} + > + {deletePasskey.isPending && ( + + )} + {t("common.delete")} + + + + +
    +
  • + ))} +
+ {deleteMsg && ( +
+ + {deleteMsg} +
+ )} +
+ ); +} diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx deleted file mode 100644 index c42fc41b4..000000000 --- a/src/components/settings/import-panel.tsx +++ /dev/null @@ -1,920 +0,0 @@ -"use client"; - -/** - * v1.15.7 — Settings → Export & Import → Import area. - * - * Web UI for the two existing import backends (issue #281: the routes - * shipped without a surface). Two clearly-separated controls: - * - * 1. Apple Health `export.zip` — multipart POST to - * `/api/import/apple-health-export` (field `file`), then poll - * `GET /api/import/apple-health-export/[jobId]/status` on a - * `refetchInterval` while the job runs, stopping on a terminal - * state (`done` / `failed`). The upload streams server-side so a - * multi-GB archive never buffers in the browser beyond the POST. - * Idempotent on the file's SHA-256 — re-uploading the same archive - * merges rather than duplicating. - * - * 2. Generic JSON import — upload a `.json` file OR paste JSON, - * validated client-side for parseability, POSTed to `/api/import`. - * A "Download example" button mints a small valid example as a Blob - * (no static asset). Returns `{ measurements, moodEntries, skipped }` - * counts. - * - * Both controls surface rate-limit (429) and error states cleanly and - * announce progress to assistive tech via `aria-live` regions. - */ - -import { useCallback, useId, useRef, useState } from "react"; -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - FileSpreadsheet, - Loader2, - Upload, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Progress } from "@/components/ui/progress"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { Textarea } from "@/components/ui/textarea"; -import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements"; - -/** - * Upper bound for the paste textareas, mirroring the 16 MB server-side body - * ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste - * before it ever reaches the route and feeds the live character counter. - */ -const MAX_PASTE_CHARS = 16 * 1024 * 1024; - -// ─────────────────────────── Section wrapper ─────────────────────────── - -export function ImportPanel() { - const { t } = useTranslations(); - return ( -
-
-

- {t("settings.sections.export.import.heading")} -

-

- {t("settings.sections.export.import.description")} -

-
-
- - - -
-
- ); -} - -// ─────────────────────────── Card shell ─────────────────────────── - -interface ImportCardShellProps { - testId: string; - icon: React.ComponentType<{ className?: string }>; - title: string; - description: string; - children: React.ReactNode; -} - -function ImportCardShell({ - testId, - icon: Icon, - title, - description, - children, -}: ImportCardShellProps) { - return ( - -
-
-

{description}

-
{children}
-
- ); -} - -// ─────────────────────────── Apple Health import ─────────────────────────── - -/** Terminal states the status poll stops on. */ -const TERMINAL_STATES: readonly string[] = ["done", "failed"]; - -interface JobStatus { - jobId: string; - status: string; - progress: { - currentPhase?: string; - recordsRead?: number; - rowsUpserted?: number; - percent?: number | null; - } | null; - result: { - totals?: { recordsRead?: number; rowsUpserted?: number }; - clinical?: { skipped?: number }; - } | null; - failureReason: string | null; -} - -function AppleHealthImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [jobId, setJobId] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [dragActive, setDragActive] = useState(false); - const dropDescId = useId(); - - const statusQuery = useQuery({ - queryKey: queryKeys.importJobStatus(jobId ?? "none"), - enabled: jobId !== null, - refetchInterval: (query) => { - const data = query.state.data as JobStatus | undefined; - if (data && TERMINAL_STATES.includes(data.status)) return false; - return 2000; - }, - queryFn: async (): Promise => { - return apiGet( - `/api/import/apple-health-export/${jobId}/status`, - { credentials: "include" }, - ); - }, - }); - - const upload = useCallback( - async (file: File) => { - setUploadError(null); - setUploading(true); - setJobId(null); - try { - const form = new FormData(); - form.append("file", file); - const res = await apiFetchRaw("/api/import/apple-health-export", { - method: "POST", - credentials: "include", - body: form, - }); - if (res.status === 429) { - setUploadError( - t("settings.sections.export.import.appleHealth.rateLimited"), - ); - return; - } - if (res.status === 413) { - setUploadError( - t("settings.sections.export.import.appleHealth.tooLarge"), - ); - return; - } - if (!res.ok) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - const body = (await res.json()).data as { - jobId: string; - status: string; - }; - if (!body?.jobId) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - setJobId(body.jobId); - } catch { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - } finally { - setUploading(false); - } - }, - [t], - ); - - function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (file) void upload(file); - // Reset so picking the same file twice re-fires the change event. - e.target.value = ""; - } - - function onDrop(e: React.DragEvent) { - e.preventDefault(); - setDragActive(false); - const file = e.dataTransfer.files?.[0]; - if (file) void upload(file); - } - - const status = statusQuery.data ?? null; - const isRunning = status !== null && !TERMINAL_STATES.includes(status.status); - const isDone = status?.status === "done"; - const isFailed = status?.status === "failed"; - const busy = uploading || isRunning; - const percent = status?.progress?.percent ?? null; - - return ( - - {/* Keyboard-operable drop area. The hidden file input does the - actual file selection; the div forwards Enter/Space to it. */} -
!busy && fileInputRef.current?.click()} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !busy) { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - if (!busy) setDragActive(true); - }} - onDragLeave={() => setDragActive(false)} - onDrop={(e) => !busy && onDrop(e)} - className={cn( - "border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none", - dragActive && "border-primary bg-primary/5", - busy && "pointer-events-none opacity-60", - )} - > -
- - -

- {t("settings.sections.export.import.appleHealth.idempotencyNote")} -

- - {/* Live progress / outcome — announced to assistive tech. */} -
- {uploading && ( -

- - {t("settings.sections.export.import.appleHealth.uploading")} -

- )} - {isRunning && ( -
-

- - {t( - `settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`, - )} -

- {typeof percent === "number" && } - {typeof status?.progress?.rowsUpserted === "number" && ( -

- {t("settings.sections.export.import.appleHealth.rowsImported", { - count: status.progress.rowsUpserted, - })} -

- )} -
- )} - {isDone && ( -
-
- )} - {(isFailed || uploadError) && ( -

-

- )} -
- -
- -
-
- ); -} - -// ─────────────────────────── Generic JSON import ─────────────────────────── - -/** - * Small valid example payload, minted as a downloadable Blob by the - * "Download example" button and used by the docs. Exported so a test can - * assert it stays a valid import body — the button and the route schema - * must never drift. - */ -export const EXAMPLE_IMPORT = { - measurements: [ - { - type: "WEIGHT", - value: 80.5, - unit: "kg", - measuredAt: "2026-05-01T08:00:00.000Z", - source: "manual", - notes: "morning", - }, - { - type: "BLOOD_PRESSURE_SYS", - value: 120, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - { - type: "BLOOD_PRESSURE_DIA", - value: 80, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - ], - moodEntries: [ - { - date: "2026-05-01", - mood: "GUT", - score: 4, - tags: "work,exercise", - }, - ], -}; - -interface JsonImportResult { - measurements: number; - moodEntries: number; - skipped: number; -} - -/** - * Client-side parse guard for the JSON-import textarea. Returns the - * parsed value when the text is valid JSON, otherwise a failure marker — - * we never POST an unparseable body. Exported for unit testing so the - * guard is covered without a browser. - */ -export function parseImportJson( - text: string, -): { ok: true; value: unknown } | { ok: false } { - try { - return { ok: true, value: JSON.parse(text) }; - } catch { - return { ok: false }; - } -} - -function JsonImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [text, setText] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const textareaId = useId(); - - async function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - setError(null); - setResult(null); - try { - const content = await file.text(); - setText(content); - } catch { - setError(t("settings.sections.export.import.json.readFailed")); - } - } - - function downloadExample() { - const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "healthlog-import-example.json"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } - - async function handleImport() { - setError(null); - setResult(null); - const parsed = parseImportJson(text); - if (!parsed.ok) { - setError(t("settings.sections.export.import.json.invalidJson")); - return; - } - const payload = parsed.value; - setBusy(true); - try { - const res = await apiFetchRaw("/api/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }); - if (res.status === 429) { - setError(t("settings.sections.export.import.json.rateLimited")); - return; - } - if (res.status === 422) { - const body = await res.json().catch(() => null); - setError( - body?.error ?? - t("settings.sections.export.import.json.invalidPayload"), - ); - return; - } - if (!res.ok) { - setError(t("settings.sections.export.import.json.failed")); - return; - } - const data = (await res.json()).data as JsonImportResult; - setResult({ - measurements: data?.measurements ?? 0, - moodEntries: data?.moodEntries ?? 0, - skipped: data?.skipped ?? 0, - }); - } catch { - setError(t("settings.sections.export.import.json.failed")); - } finally { - setBusy(false); - } - } - - return ( - -
- -