/** * Non-component helpers for settings sections: the busy/notice/error save trio * plus formatting and field-styling utilities. Kept out of * settings-primitives.tsx so that file stays export-only-components * (react-refresh requirement). */ import { getLocale, translate } from '@app/i18n' import { ApiFailure } from '@app/lib' import { useState } from 'react' /** * Fields lean on the shared stylesheet for the border weight, corner radius and * focus ring, so this only carries layout and type. */ export const inputCls = 'input h-10 w-full max-w-[440px] rounded-md border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink' export const selectCls = 'select h-10 w-full max-w-[440px] rounded-md border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink' export const textareaCls = 'textarea w-full max-w-[440px] rounded-md border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] text-shell-ink' /** * `ApiFailure` carries the server's own wording; a plain Error thrown by a * section is local copy written for the user (size limits, missing ids), so it * is shown as-is rather than replaced by the generic fallback. */ export function errText(err: unknown) { if (err instanceof ApiFailure) return err.message if (err instanceof Error && err.message) return err.message return translate(getLocale(), 'ui.actionFailed') } export function useSave() { const [busy, setBusy] = useState(false) const [notice, setNotice] = useState(null) const [error, setError] = useState(null) async function run(fn: () => Promise, ok?: string | ((value: T) => string)) { setBusy(true) setNotice(null) setError(null) try { const value = await fn() if (ok) setNotice(typeof ok === 'function' ? ok(value) : ok) } catch (err) { setError(errText(err)) } finally { setBusy(false) } } return { busy, notice, error, run } } /** * Rust's time crate emits `… +00:00:00` (seconds inside the UTC offset), which * `Date` rejects — fold it back to `+00:00` before parsing. Every date the * settings read out of the space API goes through here. */ export function parseDate(value?: string | null): Date | null { if (!value) return null const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')) return Number.isNaN(date.getTime()) ? null : date } export function fmtDate(value?: string | null) { if (!value) return '—' const date = parseDate(value) if (!date) return value // Locale-aware so the settings tables switch conventions along with the UI // language (the old hardcoded `zh-CN` made the whole app read as Chinese). return new Intl.DateTimeFormat(getLocale(), { dateStyle: 'medium', timeStyle: 'short' }).format(date) } /** `2026-09-06T02:00:00+00:00` → the `YYYY-MM-DDTHH:mm` that `` wants. */ export function toDateInput(value?: string | null) { const date = parseDate(value) if (!date) return '' const pad = (n: number) => String(n).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` } /** Read a file as the `data_base64` payload the image endpoints expect. */ export function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onerror = () => reject(new Error(translate(getLocale(), 'ui.readFileFailed'))) reader.onload = () => { const result = String(reader.result ?? '') // `data:image/png;base64,AAAA…` → the part after the comma. resolve(result.slice(result.indexOf(',') + 1)) } reader.readAsDataURL(file) }) }