MyKindlebox/mykindlebox/client/src/pages/Settings.jsx

301 lines
11 KiB
JavaScript

import { useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Button from '../components/Button'
import BreedSelect from '../components/BreedSelect'
import { useAuth } from '../authContext'
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
const WEIGHT_UNIT_OPTIONS = [
{ value: 'decimal_lbs', label: 'Decimal lbs' },
{ value: 'lbs_oz', label: 'lbs + oz' },
]
function Toast({ message }) {
if (!message) return null
return (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 rounded-md bg-text-primary px-4 py-2 font-body text-sm text-surface shadow-lg md:bottom-6">
{message}
</div>
)
}
function Settings() {
const { mode, logout } = useAuth()
const queryClient = useQueryClient()
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const [form, setForm] = useState(null)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState(null)
const importInputRef = useRef(null)
const [pendingFile, setPendingFile] = useState(null)
const [importing, setImporting] = useState(false)
// Seed the form from settings exactly once (render-phase state adjustment):
// form is null only until settings first load, so a later settings refetch
// can't clobber the user's in-progress edits.
if (settings && form === null) {
setForm({
rabbitry_name: settings.rabbitry_name ?? '',
weight_unit: settings.weight_unit ?? 'decimal_lbs',
kit_prefix: settings.kit_prefix ?? '',
default_breed: settings.default_breed ?? '',
theme: settings.theme ?? DEFAULT_THEME,
})
}
useEffect(() => {
if (!toast) return
const timer = setTimeout(() => setToast(null), 2500)
return () => clearTimeout(timer)
}, [toast])
async function handleSave() {
setSaving(true)
try {
await updateSettings(form)
setToast('Settings saved')
} catch (err) {
setToast(err.message || 'Failed to save settings')
} finally {
setSaving(false)
}
}
function handleThemeSelect(themeId) {
setForm((prev) => ({ ...prev, theme: themeId }))
// Apply immediately for live preview; handleSave persists it to the backend.
applyTheme(themeId)
}
function handleExport() {
window.location.href = exportDatabaseUrl()
}
function handleFilePicked(e) {
const file = e.target.files?.[0]
// Reset the input so picking the same file again still fires onChange.
e.target.value = ''
if (!file) return
// Validate before uploading: an oversized file gets rejected by the server
// mid-stream, which the browser surfaces as an opaque "network error" rather
// than a readable message — so catch it (and obvious wrong file types) here.
const name = file.name.toLowerCase()
if (!name.endsWith('.csv') && !name.endsWith('.json')) {
setToast('Please choose a .csv or .json file')
return
}
if (file.size > 10 * 1024 * 1024) {
setToast('File is too large (max 10 MB)')
return
}
setPendingFile(file)
}
async function handleConfirmImport() {
const file = pendingFile
setImporting(true)
try {
const result = await importData(file)
// Everything derived from rabbits/litters is now stale.
queryClient.invalidateQueries()
setToast(`Imported ${result.rabbits_imported} rabbits`)
setPendingFile(null)
} catch (err) {
setToast(err.message || 'Import failed')
} finally {
setImporting(false)
}
}
if (isLoading || !form) {
return (
<div>
<PageHeader title="Settings" />
</div>
)
}
return (
<div>
<PageHeader title="Settings" />
<div className="space-y-6 p-6 pt-0">
<Card className="space-y-4 p-6">
<div>
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>
<input
type="text"
value={form.rabbitry_name}
onChange={(e) => setForm({ ...form, rabbitry_name: e.target.value })}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Weight Display</label>
<div className="mt-1 flex gap-1">
{WEIGHT_UNIT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setForm({ ...form, weight_unit: opt.value })}
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
form.weight_unit === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Kit Prefix</label>
<input
type="text"
value={form.kit_prefix}
onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })}
placeholder="e.g. RAG"
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="mt-1 font-body text-xs text-text-secondary">
Auto-generated kit names use this prefix (e.g. "{form.kit_prefix || 'Kit'}1").
</p>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Default Breed</label>
<BreedSelect
value={form.default_breed}
onChange={(breed) => setForm({ ...form, default_breed: breed })}
/>
<p className="mt-1 font-body text-xs text-text-secondary">
Pre-fills the breed field when adding a new rabbit.
</p>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Theme</label>
<p className="mt-1 font-body text-xs text-text-secondary">
Selecting a theme previews it instantly. Save to keep it across your devices.
</p>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{THEMES.map((theme) => (
<button
key={theme.id}
type="button"
onClick={() => handleThemeSelect(theme.id)}
aria-pressed={form.theme === theme.id}
className={`flex items-center gap-3 rounded-md border px-3 py-2 text-left ${
form.theme === theme.id
? 'border-accent ring-2 ring-accent'
: 'border-border hover:bg-surface-sunken'
}`}
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border"
style={{ backgroundColor: theme.swatch.surface }}
>
<span
className="h-4 w-4 rounded-full"
style={{ backgroundColor: theme.swatch.accent }}
/>
</span>
<span className="min-w-0">
<span className="block truncate font-body text-sm font-medium text-text-primary">
{theme.label}
</span>
<span className="block truncate font-body text-xs text-text-secondary">
{theme.description}
</span>
</span>
</button>
))}
</div>
</div>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</Card>
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Export Database</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
Download a full backup of the SQLite database file.
</p>
</div>
<Button variant="secondary" onClick={handleExport}>
Export Database
</Button>
</Card>
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Import Data</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
Load rabbits from a CSV or JSON file. This replaces your entire herd —{' '}
<span className="font-medium text-danger">all current rabbits and litters are deleted first.</span>
</p>
</div>
<input
ref={importInputRef}
type="file"
accept=".csv,.json,text/csv,application/json"
onChange={handleFilePicked}
className="hidden"
/>
<Button variant="secondary" onClick={() => importInputRef.current?.click()}>
Import from File
</Button>
</Card>
{mode === 'authed' && (
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Account</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
End this session on this device.
</p>
</div>
<Button variant="secondary" onClick={logout}>
Log Out
</Button>
</Card>
)}
</div>
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<Card className="w-full max-w-md space-y-4 p-6">
<h2 className="font-display text-lg text-text-primary">Replace all data?</h2>
<p className="font-body text-sm text-text-secondary">
Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will
permanently delete <span className="font-medium text-danger">all current rabbits and litters</span>{' '}
before loading the file. This cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPendingFile(null)} disabled={importing}>
Cancel
</Button>
<Button variant="danger" onClick={handleConfirmImport} disabled={importing}>
{importing ? 'Importing...' : 'Delete & Import'}
</Button>
</div>
</Card>
</div>
)}
<Toast message={toast} />
</div>
)
}
export default Settings