initial deployment v1.0

This commit is contained in:
RaineAllDay
2026-03-18 03:06:27 -06:00
commit eaaadd39e4
69 changed files with 10755 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function load({ cookies, url }) {
// Login and logout routes handle their own auth
if (url.pathname === '/admin/login' || url.pathname === '/admin/logout') {
return {};
}
const token = cookies.get(SESSION_COOKIE);
const session = verifySession(token);
if (!session) {
throw redirect(303, `/admin/login?redirect=${encodeURIComponent(url.pathname)}`);
}
return { admin: { username: session.username } };
}

View File

@@ -0,0 +1,43 @@
<script>
export let data;
</script>
<svelte:head>
<title>Admin — ETC PRS</title>
</svelte:head>
{#if data.admin}
<!-- Admin top bar -->
<div style="position:sticky; top:0; z-index:100; background:var(--surface);
border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px;
display:flex; align-items:center; gap:16px;">
<div style="display:flex; align-items:center; gap:10px;
border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
<div style="width:6px; height:26px; background:var(--red);
box-shadow:0 0 10px rgba(248,113,113,0.5); border-radius:2px;"></div>
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
color:var(--red); opacity:0.8; text-transform:uppercase;">Admin Console</div>
</div>
</div>
<div style="flex:1;"></div>
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
{data.admin.username}
</div>
<a class="btn" href="/" style="text-decoration:none;">← App</a>
<form method="POST" action="/admin/logout">
<button class="btn btn-danger" type="submit" style="padding:5px 12px;">Logout</button>
</form>
</div>
</div>
<div style="max-width:1400px; margin:0 auto; padding:24px 20px;">
<slot />
</div>
{:else}
<!-- Login page — no chrome -->
<slot />
{/if}

View File

@@ -0,0 +1,32 @@
import { getAdminStats, listReports, listRecentPersonalitiesAdmin, listContactMessages } from '$lib/server/db.js';
export async function load({ url }) {
const reportFilter = url.searchParams.get('reports') ?? 'open';
const messageFilter = url.searchParams.get('messages') ?? 'unread';
const adminQ = url.searchParams.get('q') ?? '';
const adminPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
const stats = getAdminStats();
const resolved = reportFilter === 'all' ? null : reportFilter === 'dismissed' ? 1 : 0;
const reports = listReports({ resolved });
const messages = listContactMessages({ unreadOnly: messageFilter === 'unread' });
const { rows: personalities, total: totalPersonalities } = listRecentPersonalitiesAdmin({
page: adminPage, limit: 25, q: adminQ
});
return {
stats,
reports,
reportFilter,
messages,
messageFilter,
personalities,
totalPersonalities,
adminPage,
adminQ,
totalPages: Math.ceil(totalPersonalities / 25)
};
}

View File

@@ -0,0 +1,647 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { makeSlug } from '$lib/shared/slugify.js';
export let data;
let deleting = {};
let dismissing = {};
let markingRead = {};
let adminSearch = data.adminQ;
let searchTimer;
// Edit state
let editingId = null; // which personality is open for editing
let editForm = {}; // form field values
let editSaving = false;
let editError = '';
// Replace binary state
let replaceId = null;
let replacePreview = null; // { current, incoming }
let replaceBytes = null;
let replaceSaving = false;
let replaceError = '';
let replaceInput; // file input ref
function startEdit(p) {
editingId = p.id;
editError = '';
editForm = {
name: p.name,
prs_name: p.prs_name ?? '',
manufacturer: p.manufacturer ?? '',
notes: p.notes ?? '',
tags: JSON.parse(p.tags ?? '[]').join(', '),
creator_handle: p.creator_handle ?? ''
};
}
function cancelEdit() {
editingId = null;
editError = '';
replacePreview = null;
replaceBytes = null;
replaceError = '';
}
async function saveEdit() {
editSaving = true;
editError = '';
try {
const res = await fetch('/api/admin/edit-personality', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingId,
name: editForm.name,
prs_name: editForm.prs_name,
manufacturer: editForm.manufacturer,
notes: editForm.notes,
tags: editForm.tags.split(',').map(t => t.trim()).filter(Boolean),
creator_handle: editForm.creator_handle
})
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b.message ?? `Error ${res.status}`);
}
await invalidateAll();
cancelEdit();
} catch (err) {
editError = err.message;
} finally {
editSaving = false;
}
}
async function handleReplaceFile(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
replaceError = '';
replacePreview = null;
replaceBytes = null;
event.currentTarget.value = '';
try {
const buf = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buf));
// Preview first
const res = await fetch('/api/admin/replace-binary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: replaceId, data, preview: true })
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b.message ?? `Error ${res.status}`);
}
const result = await res.json();
replacePreview = result;
replaceBytes = data;
} catch (err) {
replaceError = err.message;
}
}
async function confirmReplace() {
replaceSaving = true;
replaceError = '';
try {
const res = await fetch('/api/admin/replace-binary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: replaceId, data: replaceBytes, preview: false })
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b.message ?? `Error ${res.status}`);
}
await invalidateAll();
replacePreview = null;
replaceBytes = null;
replaceId = null;
} catch (err) {
replaceError = err.message;
} finally {
replaceSaving = false;
}
}
function navigate(params) {
const u = new URL($page.url);
for (const [k, v] of Object.entries(params)) {
if (v) u.searchParams.set(k, v);
else u.searchParams.delete(k);
}
goto(u.toString(), { keepFocus: true });
}
function onAdminSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => navigate({ q: adminSearch, page: '1' }), 350);
}
async function markRead(id, all = false) {
const key = all ? '__all__' : id;
markingRead[key] = true;
await fetch('/api/admin/mark-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(all ? { all: true } : { id })
});
await invalidateAll();
markingRead[key] = false;
}
async function dismissReport(id) {
dismissing[id] = true;
await fetch('/api/admin/dismiss-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
await invalidateAll();
dismissing[id] = false;
}
async function deletePersonality(id, fromReport = false) {
const key = fromReport ? `r_${id}` : id;
if (!confirm('Soft-delete this personality? It will be removed from the library and hard-deleted after 60 days.')) return;
deleting[key] = true;
await fetch('/api/admin/delete-personality', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
await invalidateAll();
deleting[key] = false;
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString(undefined, { month:'short', day:'numeric', year:'numeric' });
}
function formatTime(iso) {
return new Date(iso).toLocaleString();
}
const reasonLabels = {
'incorrect-data': 'Incorrect data',
'duplicate': 'Duplicate',
'inappropriate': 'Inappropriate',
'spam': 'Spam / test',
'other': 'Other'
};
</script>
<!-- Stats strip -->
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:1px;
background:var(--border); border-radius:4px; overflow:hidden;
margin-bottom:24px; border:1px solid var(--border);">
{#each [
{ label:'Total Personalities', value: data.stats.total, color:'var(--amber)' },
{ label:'Published Today', value: data.stats.today, color:'var(--green)' },
{ label:'Open Reports', value: data.stats.openReports, color: data.stats.openReports > 0 ? 'var(--red)' : 'var(--text2)' },
{ label:'Unread Messages', value: data.stats.unreadMessages, color: data.stats.unreadMessages > 0 ? 'var(--cyan)' : 'var(--text2)' },
{ label:'Soft Deleted', value: data.stats.deleted, color:'var(--text3)' },
] as stat}
<div style="background:var(--surface); padding:16px 20px;">
<div class="label" style="margin-bottom:6px;">{stat.label}</div>
<div style="font-family:'DM Mono',monospace; font-size:28px; font-weight:500;
color:{stat.color}; text-shadow:0 0 12px {stat.color}40; line-height:1;">
{stat.value}
</div>
</div>
{/each}
</div>
<!-- Messages section -->
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
Messages
{#if data.stats.unreadMessages > 0}
<span style="margin-left:8px; padding:2px 8px; border-radius:3px;
background:var(--cyan-dim); border:1px solid rgba(45,212,200,0.3);
color:var(--cyan); font-size:11px;">{data.stats.unreadMessages} unread</span>
{/if}
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
{#each [['unread','Unread'],['all','All']] as [val, label]}
<button class="btn" style="padding:4px 10px; font-size:11px;
{data.messageFilter === val ? 'background:var(--cyan-dim); border-color:var(--cyan); color:var(--cyan);' : ''}"
type="button" on:click={() => navigate({ messages: val })}>
{label}
</button>
{/each}
{#if data.stats.unreadMessages > 0}
<button class="btn" style="padding:4px 10px; font-size:11px;" type="button"
disabled={!!markingRead['__all__']}
on:click={() => markRead(null, true)}>
{markingRead['__all__'] ? '…' : 'Mark all read'}
</button>
{/if}
</div>
</div>
{#if data.messages.length === 0}
<div style="padding:32px; text-align:center; font-family:'DM Mono',monospace;
font-size:12px; color:var(--text3);">
No messages in this view.
</div>
{:else}
{#each data.messages as msg}
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
{msg.read ? 'opacity:0.55;' : ''}">
<div style="display:flex; align-items:flex-start; justify-content:space-between;
gap:12px; flex-wrap:wrap;">
<div style="min-width:0; flex:1;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:4px;">
{#if !msg.read}
<span style="width:6px; height:6px; border-radius:50%;
background:var(--cyan); flex-shrink:0;
box-shadow:0 0 6px rgba(45,212,200,0.6);"></span>
{/if}
<span style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
font-size:15px; letter-spacing:0.03em; color:var(--text);">
{msg.subject}
</span>
<span style="font-family:'Barlow Condensed',sans-serif; font-size:12px;
color:var(--amber); font-weight:600;">
{msg.name || 'Anonymous'}
</span>
{#if msg.email}
<a href="mailto:{msg.email}"
style="font-family:'DM Mono',monospace; font-size:11px; color:var(--cyan);
text-decoration:none;">
{msg.email}
</a>
{/if}
</div>
<div style="font-size:13px; color:var(--text2); line-height:1.6;
margin-bottom:6px; white-space:pre-wrap;">
{msg.message}
</div>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">
{formatTime(msg.created_at)}
</div>
</div>
{#if !msg.read}
<button class="btn" style="padding:5px 10px; font-size:12px; flex-shrink:0;"
type="button"
disabled={!!markingRead[msg.id]}
on:click={() => markRead(msg.id)}>
{markingRead[msg.id] ? '…' : 'Mark read'}
</button>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Reports section -->
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
Reports
{#if data.stats.openReports > 0}
<span style="margin-left:8px; padding:2px 8px; border-radius:3px;
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3);
color:var(--red); font-size:11px;">{data.stats.openReports} open</span>
{/if}
</div>
<div style="display:flex; gap:4px;">
{#each [['open','Open'],['dismissed','Dismissed'],['all','All']] as [val, label]}
<button class="btn" style="padding:4px 10px; font-size:11px;
{data.reportFilter === val ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
type="button" on:click={() => navigate({ reports: val })}>
{label}
</button>
{/each}
</div>
</div>
{#if data.reports.length === 0}
<div style="padding:32px; text-align:center; font-family:'DM Mono',monospace;
font-size:12px; color:var(--text3);">
No reports in this view.
</div>
{:else}
{#each data.reports as report}
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
{report.resolved > 0 ? 'opacity:0.5;' : ''}">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div style="min-width:0;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:4px;">
<a href="/p/{report.personality_id}/{makeSlug(report.personality_name ?? '')}"
target="_blank"
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.03em; color:var(--text); text-decoration:none;">
{report.personality_name ?? report.personality_id}
</a>
{#if report.manufacturer}
<span style="font-size:12px; color:var(--text3);">{report.manufacturer}</span>
{/if}
{#if report.deleted_at}
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
padding:1px 5px; border-radius:2px; letter-spacing:0.08em;
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3); color:var(--red);">
DELETED
</span>
{/if}
</div>
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--amber); margin-bottom:3px;">
{reasonLabels[report.reason] ?? report.reason}
</div>
{#if report.notes}
<div style="font-size:12px; color:var(--text2); margin-bottom:3px;">"{report.notes}"</div>
{/if}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">
Reported {formatTime(report.created_at)}
{#if report.resolved === 1} · Dismissed
{:else if report.resolved === 2} · Removed
{/if}
</div>
</div>
{#if report.resolved === 0}
<div style="display:flex; gap:6px; flex-shrink:0;">
<button class="btn" style="padding:5px 10px; font-size:12px;" type="button"
disabled={!!dismissing[report.id]}
on:click={() => dismissReport(report.id)}>
{dismissing[report.id] ? '…' : 'Dismiss'}
</button>
{#if !report.deleted_at}
<button class="btn btn-danger" style="padding:5px 10px; font-size:12px;" type="button"
disabled={!!deleting[`r_${report.personality_id}`]}
on:click={() => deletePersonality(report.personality_id, true)}>
{deleting[`r_${report.personality_id}`] ? '…' : 'Soft Delete'}
</button>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Personalities section -->
<div class="panel" style="overflow:hidden;">
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
All Personalities
<span style="font-weight:400; font-size:12px; color:var(--text3); margin-left:8px;">
{data.totalPersonalities} total
</span>
</div>
<input class="input" style="max-width:260px;"
type="search" placeholder="Search…"
bind:value={adminSearch}
on:input={onAdminSearch} />
</div>
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:var(--raised);">
{#each ['Fixture','Manufacturer','Ch','By','Published','Status',''] as h}
<th style="padding:9px 14px; text-align:left; font-family:'Barlow Condensed',sans-serif;
font-size:11px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase;
color:var(--text2); border-bottom:1px solid var(--border); white-space:nowrap;">
{h}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each data.personalities as p}
<!-- Data row -->
<tr style="border-bottom:{editingId === p.id ? 'none' : '1px solid var(--border)'};
{p.deleted_at ? 'opacity:0.45;' : ''}
background:{editingId === p.id ? 'var(--raised)' : ''};
transition:background 0.1s;"
on:mouseenter={(e) => { if (editingId !== p.id) e.currentTarget.style.background='var(--raised)'; }}
on:mouseleave={(e) => { if (editingId !== p.id) e.currentTarget.style.background=''; }}>
<td style="padding:9px 14px;">
<a href="/p/{p.id}/{makeSlug(p.name)}" target="_blank"
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
letter-spacing:0.03em; color:var(--text); text-decoration:none;">
{p.name}
</a>
{#if p.prs_name && p.prs_name !== p.name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);">PRS: {p.prs_name}</div>
{/if}
</td>
<td style="padding:9px 14px; font-size:12px; color:var(--text2);">{p.manufacturer || '—'}</td>
<td style="padding:9px 14px; font-family:'DM Mono',monospace; font-size:13px; color:var(--amber);">{p.channel_count}</td>
<td style="padding:9px 14px; font-size:12px; color:var(--text3);">{p.creator_handle || '—'}</td>
<td style="padding:9px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">{formatDate(p.created_at)}</td>
<td style="padding:9px 14px;">
{#if p.deleted_at}
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
padding:1px 6px; border-radius:2px; letter-spacing:0.08em;
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3); color:var(--red);">
Deleted {formatDate(p.deleted_at)}
</span>
{:else}
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
padding:1px 6px; border-radius:2px; letter-spacing:0.08em;
background:var(--green-dim); border:1px solid rgba(74,222,128,0.2); color:var(--green);">
Live
</span>
{/if}
</td>
<td style="padding:9px 10px; white-space:nowrap;">
<div style="display:flex; gap:4px;">
{#if !p.deleted_at}
{#if editingId === p.id}
<button class="btn" style="padding:4px 8px; font-size:11px; color:var(--text3);"
type="button" on:click={cancelEdit}>Cancel</button>
{:else}
<button class="btn" style="padding:4px 8px; font-size:11px;"
type="button" on:click={() => startEdit(p)}>Edit</button>
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" type="button"
disabled={!!deleting[p.id]}
on:click={() => deletePersonality(p.id)}>
{deleting[p.id] ? '…' : 'Delete'}
</button>
{/if}
{/if}
</div>
</td>
</tr>
<!-- Inline edit panel -->
{#if editingId === p.id}
<tr style="border-bottom:1px solid var(--border);">
<td colspan="7" style="padding:0; background:var(--bg);">
<div style="padding:16px 18px; border-top:1px solid var(--border2);">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px;
letter-spacing:0.1em; text-transform:uppercase; color:var(--amber);
margin-bottom:14px;">
Editing: {p.name}
</div>
<!-- Metadata fields -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px;">
<div>
<label class="label" style="display:block; margin-bottom:5px;">Library Name</label>
<input class="input" type="text" maxlength="120" bind:value={editForm.name} />
</div>
<div>
<label class="label" style="display:block; margin-bottom:5px;">
PRS Name
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
text-transform:none; color:var(--text3); margin-left:4px;">(12 char max)</span>
</label>
<input class="input" style="font-family:'DM Mono',monospace;"
type="text" maxlength="12" bind:value={editForm.prs_name} />
</div>
<div>
<label class="label" style="display:block; margin-bottom:5px;">Manufacturer</label>
<input class="input" type="text" maxlength="128" bind:value={editForm.manufacturer} />
</div>
<div>
<label class="label" style="display:block; margin-bottom:5px;">Creator Handle</label>
<input class="input" type="text" maxlength="64" bind:value={editForm.creator_handle} />
</div>
<div>
<label class="label" style="display:block; margin-bottom:5px;">
Tags
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
text-transform:none; color:var(--text3); margin-left:4px;">(comma separated)</span>
</label>
<input class="input" type="text" bind:value={editForm.tags}
placeholder="e.g. verified, 16-bit, spot" />
</div>
<div>
<label class="label" style="display:block; margin-bottom:5px;">Notes</label>
<input class="input" type="text" maxlength="1000" bind:value={editForm.notes} />
</div>
</div>
<!-- Metadata actions -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;">
<button class="btn btn-primary" style="padding:6px 14px;" type="button"
disabled={editSaving} on:click={saveEdit}>
{editSaving ? 'Saving…' : 'Save Changes'}
</button>
<button class="btn" style="padding:6px 14px;" type="button" on:click={cancelEdit}>Cancel</button>
{#if editError}
<span style="font-size:12px; color:var(--red);">{editError}</span>
{/if}
</div>
<!-- Replace binary section -->
<div style="border-top:1px solid var(--border); padding-top:14px;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px;
letter-spacing:0.1em; text-transform:uppercase; color:var(--text2);
margin-bottom:10px;">Replace Binary (.prs file)</div>
{#if replaceId !== p.id || !replacePreview}
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
on:click={() => { replaceId = p.id; replacePreview = null; replaceBytes = null; replaceError = ''; replaceInput?.click(); }}>
Choose .prs file…
</button>
<span style="font-size:12px; color:var(--text3);">
Current: {p.channel_count}ch · PRS name: {p.prs_name || '(none)'}
</span>
</div>
{/if}
<!-- Diff preview -->
{#if replaceId === p.id && replacePreview}
<div style="padding:12px 14px; border-radius:3px; border:1px solid var(--border2);
background:var(--raised); margin-bottom:10px;">
<div style="font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:700;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text2); margin-bottom:8px;">
Change Summary
</div>
<div style="display:grid; grid-template-columns:auto 1fr 1fr; gap:6px 16px; font-size:12px; align-items:center;">
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Field</span>
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Current</span>
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Incoming</span>
<span style="color:var(--text2);">PRS Name</span>
<span style="font-family:'DM Mono',monospace; color:var(--text);">{replacePreview.current.prs_name || '—'}</span>
<span style="font-family:'DM Mono',monospace;
color:{replacePreview.incoming.prs_name !== replacePreview.current.prs_name ? 'var(--amber)' : 'var(--text)'};">
{replacePreview.incoming.prs_name || '—'}
{#if replacePreview.incoming.prs_name !== replacePreview.current.prs_name}
<span style="color:var(--amber); margin-left:4px;">← changed</span>
{/if}
</span>
<span style="color:var(--text2);">Channels</span>
<span style="font-family:'DM Mono',monospace; color:var(--text);">{replacePreview.current.channel_count}</span>
<span style="font-family:'DM Mono',monospace;
color:{replacePreview.incoming.channel_count !== replacePreview.current.channel_count ? 'var(--amber)' : 'var(--text)'};">
{replacePreview.incoming.channel_count}
{#if replacePreview.incoming.channel_count !== replacePreview.current.channel_count}
<span style="color:var(--amber); margin-left:4px;">← changed</span>
{/if}
</span>
</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn btn-primary" style="padding:6px 14px; font-size:12px;" type="button"
disabled={replaceSaving} on:click={confirmReplace}>
{replaceSaving ? 'Replacing…' : 'Confirm Replace'}
</button>
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
on:click={() => { replacePreview = null; replaceBytes = null; replaceId = null; }}>
Cancel
</button>
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
on:click={() => { replacePreview = null; replaceBytes = null; replaceInput?.click(); }}>
Choose different file
</button>
{#if replaceError}
<span style="font-size:12px; color:var(--red);">{replaceError}</span>
{/if}
</div>
{/if}
{#if replaceError && !replacePreview}
<div style="margin-top:8px; font-size:12px; color:var(--red);">{replaceError}</div>
{/if}
</div>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Hidden file input for binary replacement -->
<input bind:this={replaceInput} type="file" accept=".prs,application/octet-stream"
style="display:none;" on:change={handleReplaceFile} />
<!-- Pagination -->
{#if data.totalPages > 1}
<div style="padding:12px 18px; border-top:1px solid var(--border);
display:flex; align-items:center; gap:6px; justify-content:flex-end;">
{#each Array.from({length: data.totalPages}, (_, i) => i+1) as p}
<button class="btn" style="padding:4px 10px; font-family:'DM Mono',monospace; font-size:12px;
{p === data.adminPage ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
type="button" on:click={() => navigate({ page: String(p) })}>
{p}
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,49 @@
import { redirect, fail } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import { getAdminByUsername } from '$lib/server/db.js';
import { createAdminSession, getSessionCookieOptions, SESSION_COOKIE, verifySession } from '$lib/server/session.js';
import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js';
export async function load({ cookies }) {
const token = cookies.get(SESSION_COOKIE);
if (verifySession(token)) throw redirect(303, '/admin');
return {};
}
export const actions = {
default: async ({ request, cookies }) => {
// Rate limit login attempts
const ip = getClientIp(request);
const rate = checkPublishRate(ip);
if (!rate.allowed) {
return fail(429, { error: `Too many attempts. Try again soon.` });
}
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');
if (!username || !password) {
return fail(400, { error: 'Username and password are required.', username });
}
const admin = getAdminByUsername(username);
// Always run bcrypt to prevent timing attacks
const hash = admin?.password_hash ?? '$2a$12$invalidhashtopreventtimingattack000000000000000000';
const valid = await bcrypt.compare(password, hash);
if (!admin || !valid) {
return fail(401, { error: 'Invalid username or password.', username });
}
const { token, expiresAt } = createAdminSession(admin);
cookies.set(SESSION_COOKIE, token, getSessionCookieOptions(expiresAt));
const redirectTo = String(request.headers.get('referer') ?? '').includes('redirect=')
? new URL(request.headers.get('referer')).searchParams.get('redirect') ?? '/admin'
: '/admin';
throw redirect(303, redirectTo);
}
};

View File

@@ -0,0 +1,60 @@
<script>
import { enhance } from '$app/forms';
export let form;
</script>
<svelte:head><title>Admin Login — ETC PRS</title></svelte:head>
<div style="min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;">
<div class="panel" style="width:100%; max-width:380px; padding:28px;
box-shadow:0 0 0 1px rgba(248,113,113,0.15), 0 24px 64px rgba(0,0,0,0.8);">
<!-- Title -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:24px;
padding-bottom:14px; border-bottom:1px solid var(--border);">
<div style="width:4px; height:20px; background:var(--red); border-radius:2px;
box-shadow:0 0 8px rgba(248,113,113,0.5);"></div>
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
ETC PRS Admin
</div>
<div style="font-family:'DM Mono',monospace; font-size:9px; color:var(--text3);
text-transform:uppercase; letter-spacing:0.1em; margin-top:1px;">
Sign in to continue
</div>
</div>
</div>
{#if form?.error}
<div style="margin-bottom:16px; padding:10px 12px; border-radius:3px;
border:1px solid rgba(248,113,113,0.3); background:var(--red-dim);
color:var(--red); font-size:13px;">
{form.error}
</div>
{/if}
<form method="POST" use:enhance>
<div style="margin-bottom:14px;">
<label class="label" for="username" style="display:block; margin-bottom:6px;">Username</label>
<input
id="username" name="username" type="text"
class="input" autocomplete="username"
value={form?.username ?? ''}
required autofocus
/>
</div>
<div style="margin-bottom:20px;">
<label class="label" for="password" style="display:block; margin-bottom:6px;">Password</label>
<input
id="password" name="password" type="password"
class="input" autocomplete="current-password"
required
/>
</div>
<button class="btn btn-primary" type="submit" style="width:100%; justify-content:center;">
Sign In
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { destroySession, SESSION_COOKIE } from '$lib/server/session.js';
export const actions = {
default: async ({ cookies }) => {
const token = cookies.get(SESSION_COOKIE);
destroySession(token);
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/admin/login');
}
};

View File

@@ -0,0 +1 @@
<!-- Intentionally empty — logout is handled via form POST action -->