301 lines
11 KiB
JavaScript
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
|