6ee2d8b
/**
* 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<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function run<T>(fn: () => Promise<T>, 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 `<input type=date>` 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<string> {
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)
})
}