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 [refreshing, setRefreshing] = useState(false)
|
||||
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)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
@ -1590,6 +1593,43 @@ export default function AdminPage() {
|
||||
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>) {
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
@ -1630,6 +1670,21 @@ export default function AdminPage() {
|
||||
<button className="button is-small" onClick={load} title="Reload page data">
|
||||
<i className="fas fa-sync" />
|
||||
</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>
|
||||
</div>
|
||||
{refreshMsg && (
|
||||
@ -1637,6 +1692,11 @@ export default function AdminPage() {
|
||||
{refreshMsg}
|
||||
</p>
|
||||
)}
|
||||
{restoreMsg && (
|
||||
<p className={`is-size-7 ${restoreMsg.startsWith('Error') || restoreMsg === 'Invalid backup file' ? 'has-text-danger' : 'has-text-success'}`}>
|
||||
{restoreMsg}
|
||||
</p>
|
||||
)}
|
||||
{fetchedAt && !refreshMsg && (
|
||||
<p className="is-size-7 has-text-grey">
|
||||
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