// /admin.html → CATALOG tab. Full product CRUD against D1. // // Two views, toggled by local state: // - LIST: table of every product, "+ NEW" button, click to edit // - EDITOR: form for one product, with image manager + features editor // // Every text/number field autosaves on blur (PATCH /api/admin/products/:id). // Images and features have dedicated endpoints; both run instantly so the // catalog reflects changes within ExecCatalog's next refresh window. (function () { const { useState, useEffect, useMemo, useCallback, useRef } = React; // Category list is D1-backed (see js/categories.js). Components that // render category chips should call useCategoryList() so they re-render // when admin edits / adds / deletes a category. function useCategoryList() { window.ExecCategories && window.ExecCategories.useCategories(); return (window.ExecCategories ? window.ExecCategories.list() : []); } function slugifyCategoryId(s) { return String(s || '') .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); } // ---------- root: list / editor / draft (create) ---------- function AdminCatalogApp() { // Products are now managed in catalog.json (edit the file + `git push`). // This in-page editor is retired to avoid two conflicting sources of // truth: it used to write product rows to D1, which the live storefront // and checkout no longer read. The old ProductList/ProductEditor/etc. // components below are left defined but unused. window.ExecCatalog && window.ExecCatalog.useCatalog(); const products = (window.ExecCatalog ? window.ExecCatalog.list() : []); return (

Products are managed in catalog.json

Product data lives in catalog.json in the GitHub repo. To add, edit, remove, or reprice a product, edit that file and push to{' '} main. The storefront and the checkout both read it, so your change goes live automatically after Hostinger pulls the push.

This editor is retired. Editing here would write to the old database, which the live store no longer uses — the changes would not appear. Order & key management is unaffected; use the Orders tab.

Currently live: {products.length} products (from catalog.json).

); } function RestoredAdminCatalogApp() { window.ExecCatalog && window.ExecCatalog.useCatalog(); const products = (window.ExecCatalog ? window.ExecCatalog.list() : []); const [view, setView] = useState('list'); const [selectedId, setSelectedId] = useState(null); const refresh = useCallback(async () => { if (window.ExecCatalog) await window.ExecCatalog.fetchAll({ fresh: true }); }, []); const backToList = () => { setSelectedId(null); setView('list'); }; const openEditor = (id) => { setSelectedId(id); setView('edit'); }; const onCreated = async (id) => { await refresh(); setSelectedId(id); setView('edit'); }; const selected = products.find((p) => p.id === selectedId) || null; if (view === 'new') return ; if (view === 'edit' && selected) { return ; } return ( setView('new')} /> ); } // ---------- LIST VIEW ---------- function ProductList({ products, onEdit, onRefresh, onNew }) { const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); const categories = useCategoryList(); const filtered = useMemo(() => { let arr = products; if (filter !== 'all') arr = arr.filter((p) => p.category === filter); if (query.trim()) { const q = query.trim().toLowerCase(); arr = arr.filter((p) => (p.name + ' ' + p.tag + ' ' + p.id + ' ' + p.blurb).toLowerCase().includes(q) ); } return arr; }, [products, filter, query]); const counts = useMemo(() => { const out = { total: products.length, featured: 0 }; for (const p of products) if (p.featured) out.featured++; for (const c of categories) out[c.id] = products.filter((p) => p.category === c.id).length; return out; }, [products, categories]); // The actual "open the modal" handler is owned by AdminCatalogApp; this // component just renders the trigger button. const createNew = () => onNew && onNew(); return ( <> `${c.label} ${counts[c.id] || 0}`).join(' · ') : '—'} />
// CATALOG

PRODUCTS / {filtered.length}

CATEGORY
{filtered.length === 0 && ( )} {filtered.map((p) => )}
); } // Downloads the live catalog snapshot to the admin's machine. The // GitHub Action handles the regular push-to-git backup every 15 min; // this button is for ad-hoc "I want a copy right now" moments. function ExportButton() { const [busy, setBusy] = useState(false); const onClick = async () => { setBusy(true); try { const res = await fetch('/api/admin/catalog/export', { credentials: 'same-origin' }); if (!res.ok) { alert('Export failed: ' + res.status); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const a = document.createElement('a'); a.href = url; a.download = `catalog-${stamp}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { alert('Export failed: ' + e.message); } finally { setBusy(false); } }; return ( ); } function ProductRow({ product, onEdit }) { const cover = product.images && product.images[0]; return (
onEdit(product.id)}>
{cover ? : {(product.name[0] || '?')}}
{product.tag} · {(product.category || '').toUpperCase()}
{product.name} {product.featured && ★ FEATURED}
{product.blurb}
${product.pricing?.day || 0}/d ${product.pricing?.week || 0}/w ${product.pricing?.month || 0}/mo
EDIT →
); } // Inline operational-state picker. Lives on every catalog row so admin // can flip a product to OFFLINE / UPDATING without leaving CATALOG. The // click handlers stopPropagation so changing the dropdown doesn't also // open the editor row click. function InlineStatePicker({ productId }) { window.ExecStates && window.ExecStates.useStates(); const cur = window.ExecStates ? window.ExecStates.get(productId) : { state: 'updating' }; const [busy, setBusy] = useState(false); const onChange = async (e) => { e.stopPropagation(); const next = e.target.value; if (next === cur.state) return; setBusy(true); try { await window.ExecStates.setState(productId, next, cur.note || ''); } catch { /* errors surface in console; pill won't lie */ } finally { setBusy(false); } }; return (
e.stopPropagation()} >
); } // ---------- CATEGORY PICKER (chip row + inline create + manage popover) ---------- // // Shared by ProductDraftEditor (new product) and BasicFields (existing // product). Renders one chip per D1 category, plus a "+ NEW" chip that // expands an inline create form, plus a tiny "✎" affordance on each chip // that opens a popover for rename / image upload / delete. // // Props: // value: selected category id (or null) // onChange: (id) => void — called when the admin clicks a chip or after // a brand-new category is created (auto-selects it) function CategoryPicker({ value, onChange }) { const categories = useCategoryList(); const [creating, setCreating] = useState(false); const [managingId, setManagingId] = useState(null); const managing = managingId ? categories.find((c) => c.id === managingId) : null; return ( <>
{categories.map((c) => ( ))} {!creating && ( )}
{creating && ( setCreating(false)} onCreated={(newId) => { setCreating(false); onChange(newId); }} /> )} {managing && ( setManagingId(null)} onDeleted={(deletedId) => { setManagingId(null); if (value === deletedId) onChange(null); }} /> )} ); } function CategoryCreateForm({ onCancel, onCreated }) { const [label, setLabel] = useState(''); const [idEdited, setIdEdited] = useState(false); const [idValue, setIdValue] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (idEdited) return; setIdValue(slugifyCategoryId(label)); }, [label, idEdited]); const canSubmit = !busy && label.trim().length >= 2 && idValue.length >= 2; const submit = async () => { if (!canSubmit) return; setBusy(true); setError(null); try { const res = await fetch('/api/admin/categories', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: idValue, label: label.trim().toUpperCase() }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || ('http_' + res.status)); await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true })); onCreated(data.id || idValue); } catch (e) { setError(String(e.message || e)); setBusy(false); } }; return (
{error &&
! {error}
}
Image banner is optional — upload one later via the ✎ button on the new chip.
); } function CategoryManageModal({ category, onClose, onDeleted }) { const [label, setLabel] = useState(category.label); const [busy, setBusy] = useState(null); // null | 'rename' | 'upload' | 'delete' const [error, setError] = useState(null); const [savedAt, setSavedAt] = useState(null); const fileRef = useRef(null); const renameSave = async () => { const next = label.trim(); if (!next || next === category.label) return; setBusy('rename'); setError(null); try { const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}`, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: next.toUpperCase() }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || ('http_' + res.status)); await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true })); setSavedAt(Date.now()); } catch (e) { setError(String(e.message || e)); } finally { setBusy(null); } }; const uploadImage = async (file) => { if (!file || !file.type.startsWith('image/')) return; setBusy('upload'); setError(null); try { const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}/image`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': file.type }, body: file, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || ('http_' + res.status)); await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true })); setSavedAt(Date.now()); } catch (e) { setError(String(e.message || e)); } finally { setBusy(null); } }; const doDelete = async () => { if (!window.confirm(`Delete category "${category.label}"?\n\nThis can't be undone. Categories with products attached will refuse to delete — reassign or delete those products first.`)) return; setBusy('delete'); setError(null); try { const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}`, { method: 'DELETE', credentials: 'same-origin', }); const data = await res.json().catch(() => ({})); if (res.status === 409 && data && data.error === 'category_in_use') { throw new Error(`${data.count || 'some'} products still use this category. Reassign or delete them first.`); } if (!res.ok) throw new Error(data.error || ('http_' + res.status)); await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true })); onDeleted(category.id); } catch (e) { setError(String(e.message || e)); setBusy(null); } }; return (
e.stopPropagation()}>
MANAGE CATEGORY
/ {category.id}
{savedAt &&
✓ saved
} {error &&
! {error}
}
); } // ---------- DRAFT EDITOR (used for "+ NEW PRODUCT") ---------- // // Mirrors ProductEditor's chrome (same BASIC + PRICING + FEATURES + IMAGES // cards) so creating feels identical to editing. The difference: nothing // is sent to D1 until the user clicks CREATE PRODUCT at the bottom. That // single submit runs three calls in order: // 1. POST /api/admin/products → returns the new id // 2. PUT /api/admin/products/:id/features (if any features entered) // 3. POST /api/admin/products/:id/images for each staged file // Then ExecCatalog refreshes and the user lands in the live ProductEditor // for the freshly-created product. function ProductDraftEditor({ onBack, onCreated }) { const [category, setCategory] = useState(null); const [name, setName] = useState(''); const [idEdited, setIdEdited] = useState(false); const [idValue, setIdValue] = useState(''); const [version, setVersion] = useState('v1.0.0'); const [blurb, setBlurb] = useState(''); const [description, setDescription] = useState(''); const [runtime, setRuntime] = useState('Windows 10 / 11'); const [size, setSize] = useState('External loader'); const [license, setLicense] = useState('Time-based'); const [featured, setFeatured] = useState(false); const [day, setDay] = useState(''); const [week, setWeek] = useState(''); const [month, setMonth] = useState(''); const [stateChoice, setStateChoice] = useState('updating'); // start unverified const [stateNote, setStateNote] = useState(''); const [featuresRaw, setFeaturesRaw] = useState(''); const [stagedImages, setStagedImages] = useState([]); // [{file, previewUrl}] const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Derive id from name unless the user has explicitly edited it. useEffect(() => { if (idEdited) return; setIdValue(slugify(name)); }, [name, idEdited]); // Clean up object URLs we created for staged-image previews. useEffect(() => () => stagedImages.forEach((s) => URL.revokeObjectURL(s.previewUrl)), []); const parsedFeatures = useMemo(() => parseFeatures(featuresRaw), [featuresRaw]); const onFilePicked = (files) => { const next = Array.from(files || []).map((f) => ({ file: f, previewUrl: URL.createObjectURL(f), })); setStagedImages((prev) => prev.concat(next)); }; const removeStaged = (i) => { setStagedImages((prev) => { const tile = prev[i]; if (tile) URL.revokeObjectURL(tile.previewUrl); return prev.filter((_, j) => j !== i); }); }; const canSubmit = !busy && !!category && !!name.trim() && !!idValue.trim(); const onSubmit = async () => { if (!canSubmit) return; setBusy(true); setError(null); try { // 1. Create the product row with every text/number field the user // filled in. The Worker silently ignores anything not in its // whitelist, so adding extra keys here is safe. const createRes = await fetch('/api/admin/products', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: idValue.trim(), name: name.trim(), category, version: version.trim(), blurb: blurb.trim(), description: description.trim(), runtime: runtime.trim(), size: size.trim(), license: license.trim(), featured, price_day_cents: Math.round((Number(day) || 0) * 100), price_week_cents: Math.round((Number(week) || 0) * 100), price_month_cents: Math.round((Number(month) || 0) * 100), }), }); const createData = await createRes.json(); if (!createRes.ok) throw new Error(createData.error || ('http_' + createRes.status)); const newId = createData.id; // 2. Push the chosen operational state. Worker treats this as an // upsert so writing 'updating' (the default) is harmless. if (window.ExecStates) { try { await window.ExecStates.setState(newId, stateChoice, stateNote.trim()); } catch { /* state write is non-fatal — admin can fix it from the row */ } } // 3. Replace features list in one shot if any were entered. if (parsedFeatures.length) { await fetch(`/api/admin/products/${encodeURIComponent(newId)}/features`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ features: parsedFeatures }), }); } // 4. Upload staged images sequentially so order matches the staging UI. for (const tile of stagedImages) { const f = tile.file; if (!f || !f.type.startsWith('image/')) continue; await fetch(`/api/admin/products/${encodeURIComponent(newId)}/images`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type }, body: f, }); } onCreated && onCreated(newId); } catch (e) { setError(String(e.message || e)); setBusy(false); } }; return ( <>
← BACK TO CATALOG
// NEW

{name || 'NEW PRODUCT'} {idValue && / {idValue}}

{/* BASIC card ---------------------------------------------------- */}
BASIC