initial deployment v1.0
This commit is contained in:
18
src/routes/admin/+layout.server.js
Normal file
18
src/routes/admin/+layout.server.js
Normal 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 } };
|
||||
}
|
||||
43
src/routes/admin/+layout.svelte
Normal file
43
src/routes/admin/+layout.svelte
Normal 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}
|
||||
32
src/routes/admin/+page.server.js
Normal file
32
src/routes/admin/+page.server.js
Normal 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)
|
||||
};
|
||||
}
|
||||
647
src/routes/admin/+page.svelte
Normal file
647
src/routes/admin/+page.svelte
Normal 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>
|
||||
49
src/routes/admin/login/+page.server.js
Normal file
49
src/routes/admin/login/+page.server.js
Normal 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);
|
||||
}
|
||||
};
|
||||
60
src/routes/admin/login/+page.svelte
Normal file
60
src/routes/admin/login/+page.svelte
Normal 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>
|
||||
11
src/routes/admin/logout/+page.server.js
Normal file
11
src/routes/admin/logout/+page.server.js
Normal 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');
|
||||
}
|
||||
};
|
||||
1
src/routes/admin/logout/+page.svelte
Normal file
1
src/routes/admin/logout/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Intentionally empty — logout is handled via form POST action -->
|
||||
Reference in New Issue
Block a user