Add admin backup and restore for all config files
Download button exports item-overrides, delivery-rates, categories-display, occasions, hours, and vinyl-config as a single JSON file. Restore button applies a previously downloaded backup (skips vinyl-config to avoid overwriting it). Both accessible from the admin header. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1dc8a087b6
commit
be7f98a347
@ -1514,6 +1514,9 @@ export default function AdminPage() {
|
|||||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [refreshMsg, setRefreshMsg] = useState('')
|
const [refreshMsg, setRefreshMsg] = useState('')
|
||||||
|
const [restoring, setRestoring] = useState(false)
|
||||||
|
const [restoreMsg, setRestoreMsg] = useState('')
|
||||||
|
const restoreInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Category creation state (for the Categories tab)
|
// Category creation state (for the Categories tab)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
@ -1590,6 +1593,43 @@ export default function AdminPage() {
|
|||||||
setCatMessage(cat.id ? `Created: ${cat.name}` : 'Failed to create category')
|
setCatMessage(cat.id ? `Created: ${cat.name}` : 'Failed to create category')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBackup() {
|
||||||
|
window.location.href = BASE + '/api/admin/backup'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (!confirm('This will overwrite your current item overrides, delivery rates, hours, occasions, and category settings. Continue?')) {
|
||||||
|
if (restoreInputRef.current) restoreInputRef.current.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRestoring(true)
|
||||||
|
setRestoreMsg('')
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const bundle = JSON.parse(text)
|
||||||
|
const res = await fetch(BASE + '/api/admin/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(bundle),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setRestoreMsg(`Restored: ${data.restored.join(', ')}`)
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
setRestoreMsg(`Error: ${data.error}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRestoreMsg('Invalid backup file')
|
||||||
|
} finally {
|
||||||
|
setRestoring(false)
|
||||||
|
if (restoreInputRef.current) restoreInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
setTimeout(() => setRestoreMsg(''), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
function handleSaved(id: string, patch: Partial<ItemOverride>) {
|
function handleSaved(id: string, patch: Partial<ItemOverride>) {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
@ -1630,6 +1670,21 @@ export default function AdminPage() {
|
|||||||
<button className="button is-small" onClick={load} title="Reload page data">
|
<button className="button is-small" onClick={load} title="Reload page data">
|
||||||
<i className="fas fa-sync" />
|
<i className="fas fa-sync" />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="button is-small" onClick={handleBackup} title="Download config backup">
|
||||||
|
<span className="icon is-small"><i className="fas fa-download" /></span>
|
||||||
|
<span>Backup</span>
|
||||||
|
</button>
|
||||||
|
<label className={`button is-small${restoring ? ' is-loading' : ''}`} title="Restore config from backup file" style={{ cursor: 'pointer' }}>
|
||||||
|
<span className="icon is-small"><i className="fas fa-upload" /></span>
|
||||||
|
<span>Restore</span>
|
||||||
|
<input
|
||||||
|
ref={restoreInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleRestore}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button className="button is-small" onClick={handleLogout}>Sign out</button>
|
<button className="button is-small" onClick={handleLogout}>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
{refreshMsg && (
|
{refreshMsg && (
|
||||||
@ -1637,6 +1692,11 @@ export default function AdminPage() {
|
|||||||
{refreshMsg}
|
{refreshMsg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{restoreMsg && (
|
||||||
|
<p className={`is-size-7 ${restoreMsg.startsWith('Error') || restoreMsg === 'Invalid backup file' ? 'has-text-danger' : 'has-text-success'}`}>
|
||||||
|
{restoreMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{fetchedAt && !refreshMsg && (
|
{fetchedAt && !refreshMsg && (
|
||||||
<p className="is-size-7 has-text-grey">
|
<p className="is-size-7 has-text-grey">
|
||||||
Cache: {new Date(fetchedAt).toLocaleString()}
|
Cache: {new Date(fetchedAt).toLocaleString()}
|
||||||
|
|||||||
68
estore/src/app/api/admin/backup/route.ts
Normal file
68
estore/src/app/api/admin/backup/route.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { readFileSync, existsSync, writeFileSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const DATA = path.join(process.cwd(), 'data')
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
'item-overrides',
|
||||||
|
'delivery-rates',
|
||||||
|
'categories-display',
|
||||||
|
'occasions',
|
||||||
|
'hours',
|
||||||
|
'vinyl-config',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type FileKey = typeof FILES[number]
|
||||||
|
|
||||||
|
function readFile(key: FileKey): unknown {
|
||||||
|
const p = path.join(DATA, `${key}.json`)
|
||||||
|
if (!existsSync(p)) return null
|
||||||
|
try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — download a JSON bundle of all config files */
|
||||||
|
export async function GET() {
|
||||||
|
const bundle: Record<string, unknown> = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
for (const key of FILES) {
|
||||||
|
bundle[key] = readFile(key)
|
||||||
|
}
|
||||||
|
return new NextResponse(JSON.stringify(bundle, null, 2), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Disposition': `attachment; filename="bpb-config-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST — restore from a previously downloaded bundle */
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let bundle: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
bundle = await req.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored: string[] = []
|
||||||
|
const skipped: string[] = []
|
||||||
|
|
||||||
|
for (const key of FILES) {
|
||||||
|
if (key === 'vinyl-config') continue // never overwrite vinyl-config from restore
|
||||||
|
const value = bundle[key]
|
||||||
|
if (value == null) { skipped.push(key); continue }
|
||||||
|
try {
|
||||||
|
writeFileSync(path.join(DATA, `${key}.json`), JSON.stringify(value, null, 2), 'utf-8')
|
||||||
|
restored.push(key)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: `Failed to write ${key}: ${String(err)}` }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, restored, skipped })
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user