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

209
src/app.css Normal file
View File

@@ -0,0 +1,209 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Barlow:wght@300;400;500;600&family=Barlow+Condensed:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--bg: #0d0f0e;
--surface: #131713;
--raised: #191e19;
--border: #263026;
--border2: #2f3b2f;
--amber: #e8930a;
--amber2: #f5b730;
--amber-glow: rgba(232, 147, 10, 0.18);
--amber-dim: rgba(232, 147, 10, 0.08);
--cyan: #2dd4c8;
--cyan-dim: rgba(45, 212, 200, 0.10);
--magenta: #d946a8;
--magenta-dim: rgba(217, 70, 168, 0.10);
--green: #4ade80;
--green-dim: rgba(74, 222, 128, 0.10);
--red: #f87171;
--red-dim: rgba(248, 113, 113, 0.10);
--text: #d4dbd4;
--text2: #7a9478;
--text3: #3d5c3d;
}
html, body {
@apply min-h-screen antialiased;
font-family: 'Barlow', sans-serif;
background-color: var(--bg);
color: var(--text);
/* Faint grid texture evoking a console display */
background-image:
radial-gradient(ellipse 60% 30% at 50% 0%, rgba(232,147,10,0.07) 0%, transparent 70%),
repeating-linear-gradient(0deg, transparent, transparent 23px, rgba(255,255,255,0.018) 23px, rgba(255,255,255,0.018) 24px),
repeating-linear-gradient(90deg, transparent, transparent 23px, rgba(255,255,255,0.012) 23px, rgba(255,255,255,0.012) 24px);
}
body {
selection-color: var(--amber);
@apply selection:bg-amber-500/25 selection:text-amber-100;
}
/* ── BUTTONS ── */
.btn {
@apply inline-flex items-center gap-2 px-3 py-2 text-sm font-medium transition
disabled:cursor-not-allowed disabled:opacity-40;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 14px;
border-radius: 4px;
border: 1px solid var(--border2);
background: var(--raised);
color: var(--text2);
}
.btn:hover:not(:disabled) {
border-color: var(--text3);
color: var(--text);
background: #1f271f;
}
.btn:active:not(:disabled) {
background: #161d16;
}
.btn-primary {
border-color: var(--amber);
background: rgba(232,147,10,0.12);
color: var(--amber);
box-shadow: 0 0 10px rgba(232,147,10,0.15);
}
.btn-primary:hover:not(:disabled) {
background: rgba(232,147,10,0.22);
border-color: var(--amber2);
color: var(--amber2);
box-shadow: 0 0 16px rgba(232,147,10,0.25);
}
.btn-danger {
border-color: rgba(248,113,113,0.3);
background: var(--red-dim);
color: var(--red);
}
.btn-danger:hover:not(:disabled) {
background: rgba(248,113,113,0.18);
}
/* ── INPUTS ── */
.input, .select {
@apply w-full px-3 py-2 text-sm outline-none transition;
font-family: 'DM Mono', monospace;
border-radius: 3px;
border: 1px solid var(--border2);
background: var(--bg);
color: var(--text);
}
.input:focus, .select:focus {
border-color: var(--amber);
box-shadow: 0 0 0 2px rgba(232,147,10,0.12);
}
.input::placeholder {
color: var(--text3);
}
.select {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
textarea.input {
font-family: 'Barlow', sans-serif;
font-size: 13px;
}
/* ── PANELS ── */
.panel {
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 4px 16px rgba(0,0,0,0.4);
}
.menu-panel {
position: absolute;
left: 0;
top: 100%;
z-index: 30;
margin-top: 6px;
min-width: 320px;
border-radius: 4px;
border: 1px solid var(--border2);
background: #111511;
padding: 8px;
box-shadow: 0 16px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(232,147,10,0.06);
backdrop-filter: blur(12px);
}
/* ── TYPOGRAPHY UTILITIES ── */
.label {
font-family: 'Barlow Condensed', sans-serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text2);
}
.subtle {
font-size: 13px;
color: var(--text2);
}
/* ── BADGES ── */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 3px;
font-family: 'Barlow Condensed', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--border2);
background: var(--raised);
color: var(--text2);
}
/* ── LED READOUT — for stat cells ── */
.led-readout {
font-family: 'DM Mono', monospace;
font-size: 22px;
font-weight: 500;
color: var(--amber);
text-shadow: 0 0 12px rgba(232,147,10,0.5);
line-height: 1;
}
.led-readout.cyan { color: var(--cyan); text-shadow: 0 0 12px rgba(45,212,200,0.4); }
.led-readout.green { color: var(--green); text-shadow: 0 0 12px rgba(74,222,128,0.4); }
.led-readout.dim { color: var(--text2); text-shadow: none; font-size: 16px; }
/* ── ATTRIBUTE COLOR CODING ── */
/* Applied to channel card top-border and badge accents */
.attr-intensity { --attr-color: var(--amber); --attr-dim: var(--amber-dim); }
.attr-movement { --attr-color: var(--cyan); --attr-dim: var(--cyan-dim); }
.attr-color { --attr-color: var(--magenta); --attr-dim: var(--magenta-dim); }
.attr-beam { --attr-color: #a78bfa; --attr-dim: rgba(167,139,250,0.10); }
.attr-control { --attr-color: var(--text2); --attr-dim: rgba(122,148,120,0.10); }
.attr-none { --attr-color: var(--border2); --attr-dim: transparent; }
.channel-card-accent {
border-top: 2px solid var(--attr-color, var(--border2));
}
.channel-card-accent:hover {
box-shadow: 0 0 0 1px var(--attr-color, var(--border2)), inset 0 1px 0 rgba(255,255,255,0.03);
}
/* ── SCROLLBAR ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--text3); }

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<script>
import { GripVertical, Trash2 } from 'lucide-svelte';
import { DISPLAY_FORMATS } from '$lib/prs';
export let entries = [];
export let editable = false;
export let attributes = [];
export let draggingKey = null;
export let dropTarget = null;
export let onDragStart = () => {};
export let onDrop = () => {};
export let onDragEnd = () => {};
export let onUpdate = () => {};
export let onDelete = () => {};
export let onTogglePair = () => {};
function attrClass(attrName) {
const n = (attrName || '').toLowerCase();
if (n === 'intens') return 'attr-intensity';
if (['pan','tilt','pan ro','tilt ro'].includes(n)) return 'attr-movement';
if (['color','color2','cyan','magenta','yellow','clrfnc'].includes(n)) return 'attr-color';
if (n.startsWith('beam') || ['zoom','focus','iris','frost','prism'].includes(n)) return 'attr-beam';
if (['strobe','speed','speed2','contrl','contr2'].includes(n)) return 'attr-control';
return 'attr-none';
}
function confirmDelete(entry) {
const label = entry.isPair
? `channels ${entry.leader.channel}/${entry.follower.channel} (16-bit pair)`
: `channel ${entry.leader.channel}`;
if (confirm(`Delete ${label}? This cannot be undone.`)) onDelete(entry.key);
}
</script>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));" role="list">
{#each entries as entry (entry.key)}
{@const ac = attrClass(entry.leader.attribute)}
<div
data-entry-key={entry.key}
class="panel channel-card-accent {ac} transition"
style="
{draggingKey === entry.key ? 'opacity:0.35;' : ''}
{dropTarget?.key === entry.key && dropTarget?.position === 'before' ? 'box-shadow:0 -3px 0 var(--amber),inset 0 1px 0 rgba(255,255,255,0.03);' : ''}
{dropTarget?.key === entry.key && dropTarget?.position === 'after' ? 'box-shadow:0 3px 0 var(--amber),inset 0 1px 0 rgba(255,255,255,0.03);' : ''}
"
role="listitem"
aria-grabbed={editable ? draggingKey === entry.key : undefined}
on:dragover|preventDefault={(event) => onDrop(entry.key, event.clientY, false)}
on:drop={(event) => onDrop(entry.key, event.clientY, true)}
>
<!-- Header -->
<div class="flex items-start justify-between gap-2 px-4 pb-1.5 pt-2.5" style="border-bottom:1px solid var(--border);">
<div class="min-w-0">
<!-- Channel number — clearly readable -->
<div style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text2);margin-bottom:4px;letter-spacing:0.06em;">
{entry.isPair ? `CH ${entry.leader.channel} / ${entry.follower.channel}` : `CH ${entry.leader.channel}`}
{#if entry.isPair}
<span style="margin-left:6px;padding:2px 6px;border-radius:2px;background:var(--cyan-dim);border:1px solid rgba(45,212,200,0.25);color:var(--cyan);font-size:10px;font-family:'Barlow Condensed',sans-serif;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;vertical-align:middle;">16-BIT</span>
{/if}
</div>
<!-- Attribute — the hero, big and clear -->
{#if editable}
<select
class="select"
style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.1em;letter-spacing:0.04em;padding:3px 8px;height:auto;"
value={entry.leader.attributeId ?? 0}
on:change={(e) => onUpdate(entry.key, { attributeId: Number(e.currentTarget.value) })}
>
{#each attributes as attribute, index}
<option value={index}>{attribute}</option>
{/each}
</select>
{:else}
<div style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.25em;letter-spacing:0.04em;color:var(--text);line-height:1.1;">{entry.leader.attribute || 'Not Used'}</div>
{/if}
</div>
{#if editable}
<div class="flex shrink-0 items-center gap-1.5">
<button class="btn" style="padding:6px;cursor:grab;background:transparent;border-color:var(--border);" type="button" draggable="true"
aria-label={`Drag to reorder channel ${entry.leader.channel}`}
on:dragstart={() => onDragStart(entry.key)} on:dragend={onDragEnd}>
<GripVertical size={15} />
</button>
<button class="btn btn-danger" style="padding:6px;" type="button" on:click={() => confirmDelete(entry)}>
<Trash2 size={15} />
</button>
</div>
{/if}
</div>
<!-- Body -->
<div class="grid grid-cols-2 gap-x-2 gap-y-2.5 px-4 py-2.5">
<!-- Home -->
<div>
<div class="label mb-1.5">Home</div>
{#if editable}
<input class="input" type="number" min="0" max="255" value={entry.home}
on:change={(e) => onUpdate(entry.key, { home: Number(e.currentTarget.value) })} />
{:else}
<div style="font-family:'DM Mono',monospace;font-size:1.05em;font-weight:500;color:var(--text);">{entry.home}</div>
{/if}
</div>
<!-- Display -->
<div>
<div class="label mb-1.5">Display</div>
{#if editable}
<select class="select" value={entry.displayFormatId}
on:change={(e) => onUpdate(entry.key, { displayFormatId: Number(e.currentTarget.value) })}>
{#each DISPLAY_FORMATS as format, index}
<option value={index}>{format}</option>
{/each}
</select>
{:else}
<div style="font-family:'DM Mono',monospace;font-size:1.05em;font-weight:500;color:var(--text);">{entry.displayFormat}</div>
{/if}
</div>
<!-- Flags full-width -->
<div class="col-span-2">
<div class="label mb-2">Flags</div>
{#if editable}
<div class="grid grid-cols-2 gap-1.5">
{#each ['Independent', 'LTP', 'Flipped'] as flag}
<label style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:3px;border:1px solid var(--border2);background:var(--bg);cursor:pointer;">
<input type="checkbox" style="accent-color:var(--amber);cursor:pointer;width:14px;height:14px;"
checked={entry.leader.flags.includes(flag)}
on:change={(e) => {
const next = e.currentTarget.checked
? [...new Set([...entry.leader.flags, flag])]
: entry.leader.flags.filter((item) => item !== flag);
onUpdate(entry.key, { flags: entry.isPair ? [...new Set([...next, '16-bit'])] : next.filter((item) => item !== '16-bit') });
}}
/>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--text2);">{flag}</span>
</label>
{/each}
<label style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:3px;border:1px solid rgba(45,212,200,0.2);background:var(--cyan-dim);cursor:pointer;grid-column:1/-1;">
<input type="checkbox" style="accent-color:var(--cyan);cursor:pointer;width:14px;height:14px;"
checked={entry.isPair}
on:change={(e) => onTogglePair(entry.key, e.currentTarget.checked)}
/>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--cyan);">16-bit Pair</span>
</label>
</div>
{:else}
<div style="display:flex;flex-wrap:wrap;gap:5px;">
{#if entry.leader.flags.filter(f => f !== '16-bit').length}
{#each entry.leader.flags.filter(f => f !== '16-bit') as flag}
<span class="badge">{flag}</span>
{/each}
{:else}
<span class="badge" style="color:var(--text3);">None</span>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,165 @@
<script>
import { GripVertical, Trash2 } from 'lucide-svelte';
import { DISPLAY_FORMATS } from '$lib/prs';
export let entries = [];
export let editable = false;
export let attributes = [];
export let draggingKey = null;
export let dropTarget = null;
export let onDragStart = () => {};
export let onDrop = () => {};
export let onDragEnd = () => {};
export let onUpdate = () => {};
export let onDelete = () => {};
export let onTogglePair = () => {};
function attrColor(attrName) {
const n = (attrName || '').toLowerCase();
if (n === 'intens') return 'var(--amber)';
if (['pan','tilt','pan ro','tilt ro'].includes(n)) return 'var(--cyan)';
if (['color','color2','cyan','magenta','yellow','clrfnc'].includes(n)) return 'var(--magenta)';
if (n.startsWith('beam') || ['zoom','focus','iris','frost','prism'].includes(n)) return '#a78bfa';
return 'var(--text2)';
}
function confirmDelete(entry) {
const label = entry.isPair
? `channels ${entry.leader.channel}/${entry.follower.channel} (16-bit pair)`
: `channel ${entry.leader.channel}`;
if (confirm(`Delete ${label}? This cannot be undone.`)) onDelete(entry.key);
}
</script>
<div class="panel" style="overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:var(--raised);">
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);white-space:nowrap;">Ch</th>
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Attribute</th>
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Flags</th>
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Home</th>
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Display</th>
{#if editable}
<th style="padding:10px 6px;border-bottom:1px solid var(--border);width:36px;"></th>
<th style="padding:10px 6px;border-bottom:1px solid var(--border);width:36px;"></th>
{/if}
</tr>
</thead>
<tbody>
{#each entries as entry (entry.key)}
<tr
data-entry-key={entry.key}
style="
border-bottom:1px solid var(--border);
transition:background 0.1s;
{draggingKey === entry.key ? 'opacity:0.35;' : ''}
{dropTarget?.key === entry.key && dropTarget?.position === 'before' ? 'box-shadow:inset 0 2px 0 var(--amber);' : ''}
{dropTarget?.key === entry.key && dropTarget?.position === 'after' ? 'box-shadow:inset 0 -2px 0 var(--amber);' : ''}
"
on:dragover|preventDefault={(event) => onDrop(entry.key, event.clientY, false)}
on:drop={(event) => onDrop(entry.key, event.clientY, true)}
on:mouseenter={(e) => { if (draggingKey !== entry.key) e.currentTarget.style.background = 'var(--raised)'; }}
on:mouseleave={(e) => { e.currentTarget.style.background = ''; }}
>
<!-- Channel -->
<td style="padding:11px 16px;white-space:nowrap;font-family:'DM Mono',monospace;font-size:0.9em;font-weight:500;color:var(--text2);">
{entry.isPair ? `${entry.leader.channel} / ${entry.follower.channel}` : entry.leader.channel}
{#if entry.isPair}
<span style="margin-left:6px;padding:2px 5px;border-radius:2px;background:var(--cyan-dim);border:1px solid rgba(45,212,200,0.2);color:var(--cyan);font-size:10px;font-family:'Barlow Condensed',sans-serif;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;">16B</span>
{/if}
</td>
<!-- Attribute -->
<td style="padding:11px 16px;">
{#if editable}
<select class="select" style="min-width:150px;font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:0.8em;"
value={entry.leader.attributeId ?? 0}
on:change={(e) => onUpdate(entry.key, { attributeId: Number(e.currentTarget.value) })}>
{#each attributes as attribute, index}
<option value={index}>{attribute}</option>
{/each}
</select>
{:else}
<span style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.05em;letter-spacing:0.03em;color:{attrColor(entry.leader.attribute)};">
{entry.leader.attribute || 'Not Used'}
</span>
{/if}
</td>
<!-- Flags -->
<td style="padding:11px 16px;">
{#if editable}
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 20px;">
{#each ['Independent', 'LTP', 'Flipped'] as flag}
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer;">
<input type="checkbox" style="accent-color:var(--amber);cursor:pointer;width:14px;height:14px;"
checked={entry.leader.flags.includes(flag)}
on:change={(e) => {
const next = e.currentTarget.checked
? [...new Set([...entry.leader.flags, flag])]
: entry.leader.flags.filter((item) => item !== flag);
onUpdate(entry.key, { flags: entry.isPair ? [...new Set([...next, '16-bit'])] : next.filter((item) => item !== '16-bit') });
}}
/>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--text2);">{flag}</span>
</label>
{/each}
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer;">
<input type="checkbox" style="accent-color:var(--cyan);cursor:pointer;width:14px;height:14px;"
checked={entry.isPair}
on:change={(e) => onTogglePair(entry.key, e.currentTarget.checked)}
/>
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--cyan);">16-bit</span>
</label>
</div>
{:else}
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.95em;font-weight:600;color:var(--text2);">{entry.flagsLabel}</span>
{/if}
</td>
<!-- Home -->
<td style="padding:11px 16px;">
{#if editable}
<input class="input" style="max-width:80px;" type="number" min="0" max="255" value={entry.home}
on:change={(e) => onUpdate(entry.key, { home: Number(e.currentTarget.value) })} />
{:else}
<span style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text);">{entry.home}</span>
{/if}
</td>
<!-- Display -->
<td style="padding:11px 16px;">
{#if editable}
<select class="select" style="min-width:110px;" value={entry.displayFormatId}
on:change={(e) => onUpdate(entry.key, { displayFormatId: Number(e.currentTarget.value) })}>
{#each DISPLAY_FORMATS as format, index}
<option value={index}>{format}</option>
{/each}
</select>
{:else}
<span style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text2);">{entry.displayFormat}</span>
{/if}
</td>
{#if editable}
<td style="padding:11px 6px;">
<button class="btn" style="padding:5px;cursor:grab;background:transparent;border-color:var(--border);" type="button" draggable="true"
aria-label={`Drag to reorder channel ${entry.leader.channel}`}
on:dragstart={() => onDragStart(entry.key)} on:dragend={onDragEnd}>
<GripVertical size={15} />
</button>
</td>
<td style="padding:11px 6px;">
<button class="btn btn-danger" style="padding:5px;" type="button" on:click={() => confirmDelete(entry)}>
<Trash2 size={14} />
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,108 @@
<script>
export let onConfirm = (_keepTokens) => {};
export let onCancel = () => {};
// Stats passed in so the modal can show what will be cleared
export let fileCount = 0;
export let snapshotCount = 0;
let keepTokens = true; // default: preserve owner tokens
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
style="background:rgba(0,0,0,0.75);"
role="presentation"
on:click|self={onCancel}
on:keydown={(e) => e.key === 'Escape' && onCancel()}
>
<div
class="panel w-full"
style="max-width:440px; padding:24px;
box-shadow:0 0 0 1px rgba(248,113,113,0.2), 0 24px 64px rgba(0,0,0,0.8);"
role="dialog"
aria-modal="true"
aria-labelledby="clear-modal-title"
>
<!-- Title -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;
padding-bottom:12px; 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.4);"></div>
<h2 id="clear-modal-title"
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Clear Local Data
</h2>
</div>
<!-- What will be cleared -->
<p style="font-size:13px; color:var(--text2); line-height:1.7; margin-bottom:16px;">
This will permanently remove all locally stored data from your browser.
This cannot be undone.
</p>
<div style="border-radius:3px; border:1px solid var(--border); background:var(--raised);
padding:12px 14px; margin-bottom:16px;">
<div style="font-family:'Barlow Condensed',sans-serif; font-size:11px; font-weight:700;
letter-spacing:0.1em; text-transform:uppercase; color:var(--text3);
margin-bottom:8px;">Will be cleared</div>
<div style="display:flex; flex-direction:column; gap:5px;">
{#each [
{ label: 'File history', value: `${fileCount} file${fileCount !== 1 ? 's' : ''}` },
{ label: 'Snapshots', value: `${snapshotCount} snapshot${snapshotCount !== 1 ? 's' : ''}` },
{ label: 'Autosave', value: 'Current session' },
{ label: 'View preference', value: 'Card / table setting' },
{ label: 'Font scale', value: 'Size preference' },
] as row}
<div style="display:flex; justify-content:space-between; gap:12px;">
<span style="font-size:13px; color:var(--text2);">{row.label}</span>
<span style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">{row.value}</span>
</div>
{/each}
</div>
</div>
<!-- Owner token toggle -->
<label
style="display:flex; align-items:flex-start; gap:10px; padding:12px 14px;
border-radius:3px; cursor:pointer;
border:1px solid {keepTokens ? 'var(--border2)' : 'rgba(248,113,113,0.35)'};
background:{keepTokens ? 'var(--raised)' : 'var(--red-dim)'};
margin-bottom:20px; transition:all 0.15s;"
>
<input
type="checkbox"
style="margin-top:2px; accent-color:var(--red); cursor:pointer; flex-shrink:0;"
bind:checked={keepTokens}
/>
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-size:13px; font-weight:600;
letter-spacing:0.04em; text-transform:uppercase;
color:{keepTokens ? 'var(--text2)' : 'var(--red)'}; margin-bottom:3px;">
{keepTokens ? 'Keep owner tokens' : 'Also clear owner tokens'}
</div>
<div style="font-size:12px; color:var(--text3); line-height:1.5;">
{#if keepTokens}
Your publish tokens will be preserved so you can still delete library entries.
{:else}
<strong style="color:var(--red);">Warning:</strong> you will lose the ability to delete
any personalities you've published to the library.
{/if}
</div>
</div>
</label>
<!-- Actions -->
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button class="btn" type="button" on:click={onCancel}>Cancel</button>
<button
class="btn btn-danger"
type="button"
on:click={() => onConfirm(keepTokens)}
>
Clear Data
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<script>
import { clickOutside } from '$lib/utils/clickOutside';
export let icon;
export let label = '';
export let items = [];
export let emptyText = 'Nothing here yet.';
export let onSelect = () => {};
export let align = 'left';
let open = false;
let query = '';
$: normalizedQuery = query.trim().toLowerCase();
$: filteredItems = normalizedQuery
? items.filter((item) => `${item.title ?? ''} ${item.subtitle ?? ''}`.toLowerCase().includes(normalizedQuery))
: items;
</script>
<div class="relative" use:clickOutside={() => (open = false)}>
<button
class="btn"
style={open ? 'border-color:var(--amber); color:var(--amber); background:var(--amber-dim);' : ''}
type="button"
on:click={() => (open = !open)}
aria-expanded={open}
>
{#if icon}
<svelte:component this={icon} size={13} />
{/if}
<span>{label}</span>
</button>
{#if open}
<div class={`menu-panel ${align === 'right' ? 'right-0 left-auto' : ''}`} style={align === 'right' ? 'left:auto; right:0;' : ''}>
{#if items.length > 8}
<div class="mb-2">
<input class="input" type="search" bind:value={query} placeholder={`Search ${label.toLowerCase()}...`} />
</div>
{/if}
{#if filteredItems.length}
<div class="flex max-h-80 flex-col gap-1 overflow-auto pr-1">
{#each filteredItems as item}
<button
class="w-full px-3 py-2 text-left transition"
style="border-radius:3px; border:1px solid var(--border); background:var(--raised);"
type="button"
on:mouseenter={(e) => { e.currentTarget.style.borderColor='var(--border2)'; e.currentTarget.style.background='#1f271f'; }}
on:mouseleave={(e) => { e.currentTarget.style.borderColor='var(--border)'; e.currentTarget.style.background='var(--raised)'; }}
on:click={() => { open = false; query = ''; onSelect(item); }}
>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600; font-size:13px; letter-spacing:0.03em; color:var(--text);">{item.title}</div>
{#if item.subtitle}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:2px;">{item.subtitle}</div>
{/if}
</button>
{/each}
</div>
{:else}
<div class="subtle px-1 py-2">{normalizedQuery ? 'No matching items.' : emptyText}</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,57 @@
<footer style="
margin-top: auto;
border-top: 1px solid var(--border);
background: var(--surface);
padding: 24px 20px;
">
<div style="max-width:1400px; margin:0 auto;
display:flex; flex-wrap:wrap; align-items:center;
justify-content:space-between; gap:16px;">
<!-- Left: branding + tagline -->
<div style="display:flex; align-items:center; gap:12px;">
<div style="width:4px; height:22px; background:var(--amber);
border-radius:2px; box-shadow:0 0 6px rgba(232,147,10,0.4);"></div>
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
font-size:16px; letter-spacing:0.1em; text-transform:uppercase;
color:var(--text);">ETC PRS Editor</div>
<div style="font-family:'DM Mono',monospace; font-size:12px;
color:var(--cyan); margin-top:2px; opacity:0.85;">
made with <span style="color:var(--amber);"></span> for the lighting community
</div>
</div>
</div>
<!-- Center: nav links -->
<nav style="display:flex; align-items:center; gap:4px; flex-wrap:wrap;">
{#each [
{ href: '/', label: 'App' },
{ href: '/library', label: 'Library' },
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
{ href: '/disclosures', label: 'Disclosures' },
] as link}
<a
href={link.href}
style="font-family:'Barlow Condensed',sans-serif; font-size:14px; font-weight:600;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text2);
text-decoration:none; padding:5px 10px; border-radius:3px;
transition:color 0.15s;"
on:mouseenter={(e) => e.currentTarget.style.color = 'var(--amber)'}
on:mouseleave={(e) => e.currentTarget.style.color = 'var(--text2)'}
>
{link.label}
</a>
{/each}
</nav>
<!-- Right: disclaimer blurb -->
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--text2);
text-align:right; line-height:1.7; max-width:300px;">
Not affiliated with or endorsed by<br/>
ETC (Electronic Theatre Controls, Inc.)
</div>
</div>
</footer>

View File

@@ -0,0 +1,262 @@
<script>
import { FileUp, FilePlus2, FolderClock, History, Home, ChevronDown } from 'lucide-svelte';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { clickOutside } from '$lib/utils/clickOutside.js';
export let previousFiles = [];
export let snapshots = [];
export let onOpenFile = () => {};
export let onNewFile = () => {};
export let onOpenStoredFile = () => {};
export let onOpenSnapshot = () => {};
export let onGoHome = () => {};
export let currentMode = 'root';
export let currentFileId = null;
let input;
let fileMenuOpen = false;
let openReportCount = null;
onMount(async () => {
if (!browser) return;
try {
const res = await fetch('/api/admin/report-count');
if (res.ok) {
const data = await res.json();
openReportCount = data.count;
}
} catch { /* non-fatal */ }
});
$: recentFileItems = previousFiles.slice(0, 5).map((file) => ({
...file,
title: file.name,
subtitle: [file.fileName, `${file.channelCount} ch`, new Date(file.lastOpenedAt).toLocaleString()]
.filter(Boolean).join(' · ')
}));
$: snapshotItems = snapshots.map((snapshot) => ({
...snapshot,
title: snapshot.label,
subtitle: new Date(snapshot.createdAt).toLocaleString()
}));
$: subtitleText = currentMode === 'root'
? 'Ready to open or create a personality file.'
: currentMode === 'editor'
? 'Editing — unsaved changes are autosaved.'
: 'Viewing — open the editor to make changes.';
function closeFileMenu() { fileMenuOpen = false; }
</script>
<div class="sticky top-0 z-20 mb-4 overflow-visible"
style="background:var(--surface); border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
<div class="mx-auto flex items-center justify-between gap-3 px-4"
style="max-width:1400px; height:48px;">
<!-- Brand -->
<div class="flex items-center gap-3 flex-shrink-0"
style="border-right:1px solid var(--border); padding-right:16px; margin-right:4px;">
<div style="width:6px; height:26px; background:var(--amber);
box-shadow:0 0 10px rgba(232,147,10,0.6); 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(--cyan); opacity:0.8; text-transform:uppercase; margin-top:1px;">
Personality Editor
</div>
</div>
</div>
<!-- Status -->
<div class="min-w-0 flex-1">
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--amber);
opacity:0.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
{subtitleText}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Home — only shown when not on the home screen -->
{#if currentMode !== 'root'}
<button class="btn" type="button" on:click={onGoHome} title="Home">
<Home size={13} />
<span>Home</span>
</button>
{/if}
<!-- Library link -->
<a class="btn" href="/library" style="text-decoration:none;">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
Library
</a>
<!-- File menu dropdown -->
<div class="relative" use:clickOutside={closeFileMenu}>
<button
class="btn btn-primary"
type="button"
on:click={() => fileMenuOpen = !fileMenuOpen}
aria-expanded={fileMenuOpen}
>
<FileUp size={13} />
<span>File</span>
<ChevronDown size={11} style="opacity:0.7; margin-left:-2px; transition:transform 0.15s;
{fileMenuOpen ? 'transform:rotate(180deg)' : ''}" />
</button>
{#if fileMenuOpen}
<div class="menu-panel" style="right:0; left:auto; min-width:260px;">
<!-- Open -->
<button
class="w-full text-left"
style="display:flex; align-items:center; gap:10px; padding:9px 12px;
border-radius:3px; transition:background 0.1s; color:var(--text);
background:transparent; border:none; cursor:pointer; width:100%;"
type="button"
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
on:click={() => { closeFileMenu(); input?.click(); }}
>
<FileUp size={14} style="color:var(--amber); flex-shrink:0;" />
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
font-size:13px; letter-spacing:0.04em;">Open PRS file</div>
<div style="font-family:'DM Mono',monospace; font-size:10px;
color:var(--text3); margin-top:1px;">Load a .prs file from your computer</div>
</div>
</button>
<!-- New -->
<button
class="w-full text-left"
style="display:flex; align-items:center; gap:10px; padding:9px 12px;
border-radius:3px; transition:background 0.1s; color:var(--text);
background:transparent; border:none; cursor:pointer; width:100%;"
type="button"
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
on:click={() => { closeFileMenu(); onNewFile(); }}
>
<FilePlus2 size={14} style="color:var(--amber); flex-shrink:0;" />
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
font-size:13px; letter-spacing:0.04em;">New personality</div>
<div style="font-family:'DM Mono',monospace; font-size:10px;
color:var(--text3); margin-top:1px;">Start a blank fixture file</div>
</div>
</button>
<!-- Divider -->
<div style="height:1px; background:var(--border); margin:6px 0;"></div>
<!-- Recent files -->
<div style="padding:4px 12px 4px; font-family:'Barlow Condensed',sans-serif;
font-size:10px; font-weight:700; letter-spacing:0.12em;
text-transform:uppercase; color:var(--text3);">
<FolderClock size={11} style="display:inline; margin-right:5px; vertical-align:-1px;" />
Recent
</div>
{#if recentFileItems.length}
{#each recentFileItems as item}
<button
class="w-full text-left"
style="display:flex; align-items:center; gap:10px; padding:7px 12px;
border-radius:3px; transition:background 0.1s; color:var(--text);
background:transparent; border:none; cursor:pointer; width:100%;"
type="button"
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
on:click={() => { closeFileMenu(); onOpenStoredFile(item); }}
>
<div style="min-width:0;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
font-size:13px; letter-spacing:0.03em; white-space:nowrap;
overflow:hidden; text-overflow:ellipsis;">{item.title}</div>
<div style="font-family:'DM Mono',monospace; font-size:10px;
color:var(--text3); margin-top:1px; white-space:nowrap;
overflow:hidden; text-overflow:ellipsis;">{item.subtitle}</div>
</div>
</button>
{/each}
{:else}
<div style="padding:6px 12px; font-family:'DM Mono',monospace; font-size:11px;
color:var(--text3);">No recent files.</div>
{/if}
<!-- Snapshots — only when a file is open -->
{#if currentFileId && snapshotItems.length > 0}
<div style="height:1px; background:var(--border); margin:6px 0;"></div>
<div style="padding:4px 12px 4px; font-family:'Barlow Condensed',sans-serif;
font-size:10px; font-weight:700; letter-spacing:0.12em;
text-transform:uppercase; color:var(--text3);">
<History size={11} style="display:inline; margin-right:5px; vertical-align:-1px;" />
Snapshots
</div>
{#each snapshotItems as item}
<button
class="w-full text-left"
style="display:flex; align-items:center; gap:10px; padding:7px 12px;
border-radius:3px; transition:background 0.1s; color:var(--text);
background:transparent; border:none; cursor:pointer; width:100%;"
type="button"
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
on:click={() => { closeFileMenu(); onOpenSnapshot(item); }}
>
<div style="min-width:0;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
font-size:13px; letter-spacing:0.03em; white-space:nowrap;
overflow:hidden; text-overflow:ellipsis;">{item.title}</div>
<div style="font-family:'DM Mono',monospace; font-size:10px;
color:var(--text3); margin-top:1px;">{item.subtitle}</div>
</div>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Hidden file input -->
<input bind:this={input} class="hidden" type="file"
accept=".prs,application/octet-stream"
on:change={(event) => onOpenFile(event)} />
<!-- Admin badge -->
{#if openReportCount !== null}
<a href="/admin"
style="position:relative; display:inline-flex; align-items:center; justify-content:center;
font-family:'DM Mono',monospace; font-size:14px; color:var(--text3);
text-decoration:none; padding:4px 6px; border-radius:3px; transition:color 0.15s;"
on:mouseenter={(e) => e.currentTarget.style.color='var(--red)'}
on:mouseleave={(e) => e.currentTarget.style.color='var(--text3)'}
title="Admin panel">
{#if openReportCount > 0}
<span style="position:absolute; top:-3px; right:-3px; min-width:16px; height:16px;
padding:0 4px; border-radius:99px; background:var(--red); color:#fff;
font-family:'DM Mono',monospace; font-size:9px; font-weight:700;
display:flex; align-items:center; justify-content:center; line-height:1;
box-shadow:0 0 6px rgba(248,113,113,0.6);">
{openReportCount > 99 ? '99+' : openReportCount}
</span>
{/if}
</a>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,155 @@
<script>
import TagInput from './TagInput.svelte';
export let personality = null;
export let displayName = '';
export let onConfirm = async () => {};
export let onCancel = () => {};
let libraryName = displayName || personality?.name || '';
let manufacturer = '';
let tags = [];
let creatorHandle = '';
let submitting = false;
let errorMsg = '';
let manufacturerSuggestions = [];
async function loadManufacturers() {
try {
const res = await fetch('/api/manufacturers');
if (res.ok) {
const data = await res.json();
manufacturerSuggestions = data.manufacturers ?? [];
}
} catch { /* non-fatal */ }
}
loadManufacturers();
async function handleSubmit() {
if (submitting) return;
if (!libraryName.trim()) { errorMsg = 'Library name is required.'; return; }
submitting = true;
errorMsg = '';
try {
await onConfirm({ manufacturer, tags, creator_handle: creatorHandle, library_name: libraryName.trim() });
} catch (err) {
errorMsg = err?.message ?? 'Something went wrong. Please try again.';
submitting = false;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
style="background:rgba(0,0,0,0.75);"
role="presentation"
on:click|self={onCancel}
on:keydown={(e) => e.key === 'Escape' && onCancel()}
>
<div
class="panel w-full"
style="max-width:480px; padding:24px;
box-shadow:0 0 0 1px rgba(232,147,10,0.15), 0 24px 64px rgba(0,0,0,0.8);"
role="dialog"
aria-modal="true"
aria-labelledby="publish-modal-title"
>
<!-- Title -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border);">
<div style="width:4px; height:20px; background:var(--amber); border-radius:2px; box-shadow:0 0 8px rgba(232,147,10,0.5);"></div>
<h2 id="publish-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Publish to Library
</h2>
</div>
<!-- Fixture summary (read-only context) -->
<div style="padding:10px 12px; border-radius:3px; background:var(--raised); border:1px solid var(--border); margin-bottom:16px;
display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); text-transform:uppercase; letter-spacing:0.1em; margin-bottom:3px;">PRS binary name</div>
<div style="font-family:'DM Mono',monospace; font-weight:600; font-size:15px; color:var(--amber);">{personality?.name || '(untitled)'}</div>
</div>
<div style="text-align:right;">
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); text-transform:uppercase; letter-spacing:0.1em; margin-bottom:3px;">Channels</div>
<div style="font-family:'DM Mono',monospace; font-size:15px; color:var(--text2);">{personality?.channelCount ?? 0}</div>
</div>
</div>
<!-- Library name (editable) -->
<div style="margin-bottom:14px;">
<label class="label" for="pub-library-name" style="display:block; margin-bottom:6px;">
Library Name
<span style="font-weight:400; text-transform:none; letter-spacing:0; color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">shown in the library listing</span>
</label>
<input
id="pub-library-name"
class="input"
type="text"
bind:value={libraryName}
maxlength="120"
placeholder="Full fixture display name…"
/>
</div>
<!-- Manufacturer -->
<div style="margin-bottom:14px;">
<label class="label" for="pub-manufacturer" style="display:block; margin-bottom:6px;">Manufacturer</label>
<input
id="pub-manufacturer"
class="input"
type="text"
list="manufacturer-list"
bind:value={manufacturer}
placeholder="e.g. Chauvet Professional"
autocomplete="off"
/>
<datalist id="manufacturer-list">
{#each manufacturerSuggestions as m}
<option value={m}>{m}</option>
{/each}
</datalist>
</div>
<!-- Tags -->
<div style="margin-bottom:14px;">
<label class="label" style="display:block; margin-bottom:6px;">Tags</label>
<TagInput bind:tags onChange={(t) => tags = t} />
</div>
<!-- Handle -->
<div style="margin-bottom:20px;">
<label class="label" for="pub-handle" style="display:block; margin-bottom:6px;">
Your name / handle
<span style="font-weight:400; text-transform:none; letter-spacing:0; color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">(optional)</span>
</label>
<input
id="pub-handle"
class="input"
type="text"
bind:value={creatorHandle}
placeholder="e.g. Raine"
maxlength="64"
/>
</div>
{#if errorMsg}
<div style="margin-bottom:14px; 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;">
{errorMsg}
</div>
{/if}
<!-- Actions -->
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button class="btn" type="button" on:click={onCancel} disabled={submitting}>Cancel</button>
<button class="btn btn-primary" type="button" on:click={handleSubmit} disabled={submitting}>
{#if submitting}
Publishing…
{:else}
Publish
{/if}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,115 @@
<script>
import { CheckCircle, Copy, Download, ExternalLink } from 'lucide-svelte';
export let result = null; // { id, slug, url, owner_token }
export let onDone = () => {};
let urlCopied = false;
let tokenCopied = false;
function copyText(text, which) {
navigator.clipboard.writeText(text).then(() => {
if (which === 'url') { urlCopied = true; setTimeout(() => urlCopied = false, 2000); }
if (which === 'token') { tokenCopied = true; setTimeout(() => tokenCopied = false, 2000); }
});
}
function downloadToken() {
const content = [
`ETC PRS Library — Owner Token`,
`================================`,
`Personality: ${result?.url ?? ''}`,
`Token: ${result?.owner_token ?? ''}`,
``,
`Keep this token safe. It is the only way to delete this upload.`,
`It will not be shown again.`,
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `prs-token-${result?.id ?? 'unknown'}.txt`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
style="background:rgba(0,0,0,0.75);"
role="dialog"
aria-modal="true"
aria-labelledby="success-modal-title"
>
<div
class="panel w-full"
style="max-width:520px; padding:24px;
box-shadow:0 0 0 1px rgba(45,212,200,0.2), 0 24px 64px rgba(0,0,0,0.8);"
>
<!-- Title -->
<div style="display:flex; align-items:center; gap:10px; margin-bottom:20px; padding-bottom:14px; border-bottom:1px solid var(--border);">
<CheckCircle size={20} style="color:var(--cyan); flex-shrink:0;" />
<h2 id="success-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Published Successfully
</h2>
</div>
<!-- Share URL -->
<div style="margin-bottom:16px;">
<div class="label" style="margin-bottom:6px;">Share Link</div>
<div style="display:flex; gap:6px;">
<div style="flex:1; font-family:'DM Mono',monospace; font-size:12px; color:var(--cyan);
padding:8px 10px; border-radius:3px; border:1px solid var(--border2);
background:var(--bg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{result?.url ?? ''}
</div>
<button class="btn" style="flex-shrink:0; padding:6px 10px;" type="button"
on:click={() => copyText(result?.url, 'url')}>
<Copy size={13} />
{urlCopied ? 'Copied!' : 'Copy'}
</button>
<a class="btn" href={result?.url} target="_blank" rel="noopener noreferrer"
style="flex-shrink:0; padding:6px 10px; text-decoration:none;">
<ExternalLink size={13} />
</a>
</div>
</div>
<!-- Owner Token — prominent warning -->
<div style="padding:14px; border-radius:3px; border:1px solid rgba(232,147,10,0.4);
background:rgba(232,147,10,0.06); margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:8px;">
<div style="width:3px; height:16px; background:var(--amber); border-radius:2px;"></div>
<div class="label" style="color:var(--amber);">Owner Token — Save This Now</div>
</div>
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);
margin-bottom:10px; line-height:1.6;">
This token lets you delete your upload. It will <strong style="color:var(--text2);">never be shown again</strong>.
Save it somewhere safe — copy it, download it, or store it in a password manager.
</div>
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
padding:8px 10px; border-radius:3px; border:1px solid rgba(232,147,10,0.2);
background:var(--bg); word-break:break-all; margin-bottom:10px;">
{result?.owner_token ?? ''}
</div>
<div style="display:flex; gap:6px;">
<button class="btn" type="button" on:click={() => copyText(result?.owner_token, 'token')}
style="flex:1; justify-content:center;">
<Copy size={13} />
{tokenCopied ? 'Copied!' : 'Copy Token'}
</button>
<button class="btn" type="button" on:click={downloadToken}
style="flex:1; justify-content:center;">
<Download size={13} />
Download as .txt
</button>
</div>
</div>
<button class="btn btn-primary" type="button" on:click={onDone}
style="width:100%; justify-content:center;">
Done
</button>
</div>
</div>

View File

@@ -0,0 +1,144 @@
<script>
export let personalityId = '';
export let personalityName = '';
export let onDone = () => {};
export let onCancel = () => {};
const REASONS = [
{ value: 'incorrect-data', label: 'Incorrect fixture data' },
{ value: 'duplicate', label: 'Duplicate upload' },
{ value: 'inappropriate', label: 'Inappropriate content' },
{ value: 'spam', label: 'Spam / test upload' },
{ value: 'other', label: 'Other' },
];
let reason = '';
let notes = '';
let submitting = false;
let submitted = false;
let errorMsg = '';
async function handleSubmit() {
if (!reason) { errorMsg = 'Please select a reason.'; return; }
submitting = true;
errorMsg = '';
try {
const res = await fetch('/api/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ personality_id: personalityId, reason, notes })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `Error ${res.status}`);
}
submitted = true;
} catch (err) {
errorMsg = err.message;
submitting = false;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
style="background:rgba(0,0,0,0.75);"
role="presentation"
on:click|self={onCancel}
on:keydown={(e) => e.key === 'Escape' && onCancel()}
>
<div
class="panel w-full"
style="max-width:420px; padding:24px;
box-shadow:0 0 0 1px rgba(248,113,113,0.15), 0 24px 64px rgba(0,0,0,0.8);"
role="dialog"
aria-modal="true"
aria-labelledby="report-modal-title"
>
{#if submitted}
<!-- Success state -->
<div style="text-align:center; padding:12px 0;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--green); margin-bottom:10px;">
Report Submitted
</div>
<p style="font-size:13px; color:var(--text2); line-height:1.6; margin-bottom:20px;">
Thank you. Our team will review this personality shortly.
</p>
<button class="btn btn-primary" type="button" on:click={onDone}
style="width:100%; justify-content:center;">Done</button>
</div>
{:else}
<!-- Title -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;
padding-bottom:12px; 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.4);"></div>
<div>
<h2 id="report-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
font-size:15px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Report Personality
</h2>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:1px;">
{personalityName}
</div>
</div>
</div>
<!-- Reason -->
<div style="margin-bottom:14px;">
<label class="label" style="display:block; margin-bottom:8px;">Reason</label>
<div style="display:flex; flex-direction:column; gap:6px;">
{#each REASONS as r}
<label style="display:flex; align-items:center; gap:9px; padding:8px 10px;
border-radius:3px; border:1px solid {reason === r.value ? 'var(--amber)' : 'var(--border2)'};
background:{reason === r.value ? 'var(--amber-dim)' : 'var(--bg)'};
cursor:pointer; transition:border-color 0.15s;">
<input type="radio" name="reason" value={r.value} bind:group={reason}
style="accent-color:var(--amber); cursor:pointer;" />
<span style="font-family:'Barlow Condensed',sans-serif; font-size:13px; font-weight:600;
letter-spacing:0.04em; color:{reason === r.value ? 'var(--amber)' : 'var(--text2)'};">
{r.label}
</span>
</label>
{/each}
</div>
</div>
<!-- Notes -->
<div style="margin-bottom:18px;">
<label class="label" for="report-notes" style="display:block; margin-bottom:6px;">
Additional notes
<span style="font-weight:400; text-transform:none; letter-spacing:0;
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">
(optional)
</span>
</label>
<textarea
id="report-notes"
class="input resize-none"
rows="3"
maxlength="500"
placeholder="Any additional context…"
bind:value={notes}
style="font-size:13px;"
></textarea>
</div>
{#if errorMsg}
<div style="margin-bottom:14px; padding:9px 12px; border-radius:3px;
border:1px solid rgba(248,113,113,0.3); background:var(--red-dim);
color:var(--red); font-size:12px;">
{errorMsg}
</div>
{/if}
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button class="btn" type="button" on:click={onCancel} disabled={submitting}>Cancel</button>
<button class="btn btn-danger" type="button" on:click={handleSubmit} disabled={submitting || !reason}>
{submitting ? 'Submitting…' : 'Submit Report'}
</button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,133 @@
<script>
import { Pencil, Plus, Save, Download, Table2, LayoutGrid, ArrowLeft, ZoomIn, ZoomOut, RotateCcw, Globe, Trash2 } from 'lucide-svelte';
export let mode = 'viewer';
export let view = 'cards';
export let uiScale = 1.0;
export let SCALE_MIN = 0.6;
export let SCALE_MAX = 1.4;
export let onViewChange = () => {};
export let onOpenEditor = () => {};
export let onExitEditor = () => {};
export let onAddChannel = () => {};
export let onSaveSnapshot = () => {};
export let onDownload = () => {};
export let onPublish = () => {};
export let onDelete = () => {};
export let onScaleUp = () => {};
export let onScaleDown = () => {};
export let onScaleReset = () => {};
$: scalePercent = Math.round(uiScale * 100);
$: atMin = uiScale <= SCALE_MIN;
$: atMax = uiScale >= SCALE_MAX;
</script>
<div class="sticky z-10 mb-4" style="top:48px; background:var(--raised); border-bottom:1px solid var(--border); padding:8px 20px;">
<div style="max-width:1400px; margin:0 auto; display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px;">
<!-- Left: mode actions -->
<div class="flex flex-wrap items-center gap-2">
{#if mode === 'viewer'}
<button class="btn" type="button" on:click={onOpenEditor}>
<Pencil size={13} /><span>Edit</span>
</button>
<button class="btn" type="button" on:click={onPublish}>
<Globe size={13} /><span>Publish</span>
</button>
<button class="btn btn-primary" type="button" on:click={onDownload}>
<Download size={13} /><span>Export PRS</span>
</button>
<div style="width:1px; height:20px; background:var(--border2);"></div>
<button class="btn" type="button" on:click={onDelete}
style="color:var(--red); border-color:transparent;"
title="Delete this file from local history">
<Trash2 size={13} />
<span>Delete</span>
</button>
{:else}
<button class="btn" type="button" on:click={onExitEditor}>
<ArrowLeft size={13} /><span>Exit Editor</span>
</button>
<button class="btn" type="button" on:click={onAddChannel}>
<Plus size={13} /><span>Add Channel</span>
</button>
<button class="btn" type="button" on:click={onSaveSnapshot}>
<Save size={13} /><span>Snapshot</span>
</button>
<button class="btn btn-primary" type="button" on:click={onDownload}>
<Download size={13} /><span>Export PRS</span>
</button>
<div style="width:1px; height:20px; background:var(--border2);"></div>
<button class="btn" type="button" on:click={onDelete}
style="color:var(--red); border-color:transparent;"
title="Delete this file from local history">
<Trash2 size={13} />
<span>Delete</span>
</button>
{/if}
</div>
<!-- Right: view toggle + font scale -->
<div class="flex items-center gap-2">
<!-- Font scale control -->
<div class="flex items-center" style="border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
<button
class="btn"
style="border:none; border-radius:0; padding:5px 8px;"
type="button"
disabled={atMin}
title="Decrease text size"
on:click={onScaleDown}
>
<ZoomOut size={13} />
</button>
<button
class="btn"
style="border:none; border-radius:0; padding:5px 8px; min-width:44px; justify-content:center; font-family:'DM Mono',monospace; font-size:11px; color:var(--text2);"
type="button"
title="Reset text size"
on:click={onScaleReset}
>
{scalePercent}%
</button>
<button
class="btn"
style="border:none; border-radius:0; padding:5px 8px;"
type="button"
disabled={atMax}
title="Increase text size"
on:click={onScaleUp}
>
<ZoomIn size={13} />
</button>
</div>
<!-- Separator -->
<div style="width:1px; height:20px; background:var(--border2);"></div>
<!-- View toggle -->
<div class="flex" style="border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
<button
class="btn"
style="border:none; border-radius:0; padding:5px 10px; {view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
type="button"
on:click={() => onViewChange('cards')}
>
<LayoutGrid size={13} /><span>Cards</span>
</button>
<div style="width:1px; background:var(--border2);"></div>
<button
class="btn"
style="border:none; border-radius:0; padding:5px 10px; {view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
type="button"
on:click={() => onViewChange('table')}
>
<Table2 size={13} /><span>Table</span>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<script>
export let tags = [];
export let onChange = () => {};
let inputValue = '';
function addTag() {
const raw = inputValue.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
if (!raw || tags.includes(raw) || tags.length >= 10) return;
tags = [...tags, raw];
inputValue = '';
onChange(tags);
}
function removeTag(tag) {
tags = tags.filter(t => t !== tag);
onChange(tags);
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
} else if (e.key === 'Backspace' && !inputValue && tags.length) {
removeTag(tags[tags.length - 1]);
}
}
</script>
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:5px; padding:6px 8px;
border-radius:3px; border:1px solid var(--border2); background:var(--bg);
min-height:38px; cursor:text;"
on:click={() => document.getElementById('tag-input-field').focus()}
role="none"
>
{#each tags as tag}
<span style="display:inline-flex; align-items:center; gap:4px; padding:2px 8px;
border-radius:3px; background:var(--amber-dim); border:1px solid rgba(232,147,10,0.3);
font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:600;
letter-spacing:0.06em; text-transform:uppercase; color:var(--amber);">
{tag}
<button type="button"
style="background:none; border:none; cursor:pointer; color:var(--amber); opacity:0.6;
padding:0; line-height:1; font-size:14px;"
on:click|stopPropagation={() => removeTag(tag)}>×</button>
</span>
{/each}
<input
id="tag-input-field"
type="text"
bind:value={inputValue}
on:keydown={handleKeydown}
on:blur={addTag}
placeholder={tags.length === 0 ? 'Add tags (e.g. wash, moving-head)…' : ''}
style="flex:1; min-width:120px; background:none; border:none; outline:none;
font-family:'DM Mono',monospace; font-size:12px; color:var(--text);
padding:0;"
/>
</div>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px;">
Press Enter or comma to add · {tags.length}/10
</div>

View File

@@ -0,0 +1,24 @@
<script>
import { CheckCircle } from 'lucide-svelte';
export let message = '';
export let visible = false;
</script>
{#if visible}
<div
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2"
style="display:flex; align-items:center; gap:8px;
padding: 8px 16px;
border-radius:3px;
border:1px solid rgba(232,147,10,0.5);
background: rgba(13,15,14,0.95);
box-shadow: 0 0 20px rgba(232,147,10,0.2), 0 8px 32px rgba(0,0,0,0.6);
backdrop-filter: blur(12px);"
role="status"
aria-live="polite"
>
<CheckCircle size={14} style="color:var(--amber); flex-shrink:0;" />
<span style="font-family:'Barlow Condensed',sans-serif; font-weight:600; font-size:12px; letter-spacing:0.08em; text-transform:uppercase; color:var(--amber);">{message}</span>
</div>
{/if}

320
src/lib/prs.js Normal file
View File

@@ -0,0 +1,320 @@
export const FILE_SIZE = 540;
export const CHANNEL_COUNT_OFFSET = 0x0d;
export const NAME_OFFSET = 0x0e;
export const NAME_LEN = 12;
export const CHANNEL_BLOCK_OFFSET = 0x1c;
export const CHANNEL_RECORD_SIZE = 8;
export const MAX_CHANNEL_RECORDS = 64;
export const ATTRIBUTE_NAMES = [
'Not Used', 'Intens', 'Pan', 'Tilt', 'Color', 'Color2', 'Cyan', 'Magenta', 'Yellow', 'Gobo',
'GoboRo', 'Gobo2', 'Gobo2R', 'F/X', 'F/X Ro', 'Prism', 'Strobe', 'Zoom', 'Focus', 'Iris',
'Frost', 'Pan Ro', 'Tilt Ro', 'Beam1a', 'Beam1b', 'Beam2a', 'Beam2b', 'Beam3a', 'Beam3b',
'Beam4a', 'Beam4b', 'BeamRo', 'Speed', 'Speed2', 'Contrl', 'Contr2', 'Resrv9', 'Resrv8',
'Resrv7', 'Resrv6', 'Resrv5', 'Resrv4', 'Resrv3', 'Resrv2', 'Resrv1', 'ClrFnc', 'LensW/l',
'ChkSum', 'User17', 'User16', 'User15', 'User14', 'User13', 'User12', 'User11', 'User10',
'User9', 'User8', 'User7', 'User6', 'User5', 'User4', 'User3', 'User2', 'User1'
];
export const DISPLAY_FORMATS = ['Percent', 'Raw', 'Hex', 'Text'];
export const FLAGS = {
0x01: 'Independent',
0x02: 'LTP',
0x04: '16-bit',
0x08: 'Flipped'
};
const textDecoder = new TextDecoder('ascii');
const textEncoder = new TextEncoder();
export function parseFlags(flagByte) {
return Object.entries(FLAGS)
.filter(([bit]) => flagByte & Number(bit))
.map(([, name]) => name);
}
export function composeFlags(flagNames = []) {
let value = 0;
for (const [bit, name] of Object.entries(FLAGS)) {
if (flagNames.includes(name)) value |= Number(bit);
}
return value;
}
export function ensureChannelDefaults(channel = {}, index = 0) {
const attributeId = Number.isInteger(channel.attributeId)
? channel.attributeId
: ATTRIBUTE_NAMES.indexOf(channel.attribute ?? '');
const displayFormatId = Number.isInteger(channel.displayFormatId)
? channel.displayFormatId
: Math.max(0, DISPLAY_FORMATS.indexOf(channel.displayFormat ?? 'Percent'));
return {
uid: channel.uid ?? cryptoLikeId('ch'),
channel: Number.isInteger(channel.channel) ? channel.channel : index + 1,
attributeId: attributeId >= 0 ? attributeId : null,
attribute: attributeId >= 0 ? ATTRIBUTE_NAMES[attributeId] : '',
home: Number.isInteger(channel.home) ? channel.home : 0,
displayFormatId,
displayFormat: DISPLAY_FORMATS[displayFormatId] ?? DISPLAY_FORMATS[0],
rawFlagByte: Number.isInteger(channel.rawFlagByte) ? channel.rawFlagByte : 0,
flags: Array.isArray(channel.flags) ? [...channel.flags] : [],
is16BitPairLeader: Boolean(channel.is16BitPairLeader),
is16BitPairFollower: Boolean(channel.is16BitPairFollower),
pairOwnerUid: channel.pairOwnerUid ?? null
};
}
export function parsePRS(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
if (bytes.length !== FILE_SIZE) {
throw new Error(`Expected ${FILE_SIZE} bytes, got ${bytes.length}.`);
}
const channelCount = bytes[CHANNEL_COUNT_OFFSET];
if (channelCount > MAX_CHANNEL_RECORDS) {
throw new Error(`Channel count ${channelCount} exceeds max ${MAX_CHANNEL_RECORDS}.`);
}
const rawName = bytes.slice(NAME_OFFSET, NAME_OFFSET + NAME_LEN);
const nullIndex = rawName.indexOf(0);
const nameBytes = nullIndex === -1 ? rawName : rawName.slice(0, nullIndex);
const name = textDecoder.decode(nameBytes);
const channels = [];
for (let i = 0; i < channelCount; i += 1) {
const offset = CHANNEL_BLOCK_OFFSET + i * CHANNEL_RECORD_SIZE;
const flagByte = bytes[offset + 0];
const attributeId = bytes[offset + 4];
const home = bytes[offset + 5];
const displayFormatId = bytes[offset + 6];
const zeroBasedChannel = bytes[offset + 7];
channels.push(ensureChannelDefaults({
channel: zeroBasedChannel + 1,
attributeId,
home,
displayFormatId,
rawFlagByte: flagByte,
flags: parseFlags(flagByte)
}, i));
}
return {
name,
channelCount,
channels: annotate16BitPairs(channels)
};
}
export function annotate16BitPairs(channels) {
const normalized = channels.map(ensureChannelDefaults);
return normalized.map((channel, index) => {
// Leader: strictly determined by the 16-bit flag on this channel
const isLeader = channel.flags.includes('16-bit');
// Follower: strictly determined by the previous channel having the 16-bit flag
const isFollower = index > 0 && normalized[index - 1].flags.includes('16-bit');
return {
...channel,
is16BitPairLeader: isLeader,
is16BitPairFollower: isFollower,
pairOwnerUid: isFollower ? normalized[index - 1].uid : null
};
});
}
export function createBlankPersonality() {
return {
name: '',
channelCount: 0,
channels: []
};
}
export function normalizePersonality(personality) {
const channels = annotate16BitPairs((personality?.channels ?? []).map(ensureChannelDefaults));
channels.forEach((channel, index) => {
channel.channel = index + 1;
});
return {
name: String(personality?.name ?? '').slice(0, NAME_LEN),
channelCount: channels.length,
channels
};
}
export function buildPRS(personality) {
const normalized = normalizePersonality(personality);
const emitted = normalized.channels.filter((channel) => Number.isInteger(channel.attributeId));
if (emitted.length > MAX_CHANNEL_RECORDS) {
throw new Error(`Too many emitted channels. Max is ${MAX_CHANNEL_RECORDS}.`);
}
const out = new Uint8Array(FILE_SIZE);
out[CHANNEL_COUNT_OFFSET] = emitted.length;
const name = String(normalized.name ?? '').slice(0, NAME_LEN);
const encoded = textEncoder.encode(name);
out.set(encoded.slice(0, NAME_LEN), NAME_OFFSET);
emitted.forEach((channel, index) => {
const offset = CHANNEL_BLOCK_OFFSET + index * CHANNEL_RECORD_SIZE;
out[offset + 0] = composeFlags(channel.flags);
out[offset + 1] = 0;
out[offset + 2] = 0;
out[offset + 3] = 0;
out[offset + 4] = channel.attributeId;
out[offset + 5] = Math.max(0, Math.min(255, Number(channel.home || 0)));
out[offset + 6] = Math.max(0, Math.min(DISPLAY_FORMATS.length - 1, Number(channel.displayFormatId || 0)));
out[offset + 7] = index;
});
return out;
}
export function cryptoLikeId(prefix = 'id') {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export function buildVisibleEntries(channels) {
const normalized = normalizePersonality({ channels }).channels;
const entries = [];
for (let index = 0; index < normalized.length; index += 1) {
const leader = normalized[index];
if (leader.is16BitPairFollower) continue;
const follower = leader.is16BitPairLeader ? normalized[index + 1] : null;
entries.push({
key: leader.uid,
startIndex: index,
endIndex: follower ? index + 1 : index,
isPair: Boolean(follower),
title: follower ? `${leader.channel}/${follower.channel} - ${leader.attribute || 'Not Used'}` : `${leader.channel} - ${leader.attribute || 'Not Used'}`,
leader,
follower,
home: follower ? follower.home : leader.home,
displayFormatId: follower ? follower.displayFormatId : leader.displayFormatId,
displayFormat: follower ? follower.displayFormat : leader.displayFormat,
flagsLabel: leader.flags.length ? leader.flags.join(', ') : 'No flags'
});
}
return entries;
}
export function toggle16BitPair(channels, leaderUid, enabled) {
const normalized = normalizePersonality({ channels }).channels;
const index = normalized.findIndex((channel) => channel.uid === leaderUid);
if (index === -1) return normalized;
const leader = normalized[index];
const follower = normalized[index + 1];
const currentlyPaired = leader.flags.includes('16-bit');
if (enabled && !currentlyPaired) {
leader.flags = mergeFlags(leader.flags, ['16-bit']);
const mirroredFlags = leader.flags.filter((flag) => flag !== '16-bit');
normalized.splice(index + 1, 0, ensureChannelDefaults({
channel: leader.channel + 1,
attributeId: leader.attributeId,
home: leader.home,
displayFormatId: leader.displayFormatId,
rawFlagByte: composeFlags(mirroredFlags),
flags: mirroredFlags,
is16BitPairFollower: true,
pairOwnerUid: leader.uid
}, index + 1));
}
if (!enabled && currentlyPaired) {
leader.flags = leader.flags.filter((flag) => flag !== '16-bit');
normalized.splice(index + 1, 1);
}
return normalizePersonality({ channels: normalized }).channels;
}
export function updateEntry(channels, entryKey, patch) {
const normalized = normalizePersonality({ channels }).channels;
const index = normalized.findIndex((channel) => channel.uid === entryKey);
if (index === -1) return normalized;
const leader = normalized[index];
const follower = leader.flags.includes('16-bit') && normalized[index + 1]
? normalized[index + 1]
: null;
if ('attributeId' in patch) {
leader.attributeId = patch.attributeId;
leader.attribute = ATTRIBUTE_NAMES[patch.attributeId] ?? '';
if (follower) {
follower.attributeId = patch.attributeId;
follower.attribute = leader.attribute;
}
}
if ('flags' in patch) {
leader.flags = [...patch.flags];
if (follower) {
follower.flags = patch.flags.filter((flag) => flag !== '16-bit');
}
}
if ('home' in patch) {
if (follower) follower.home = patch.home;
else leader.home = patch.home;
}
if ('displayFormatId' in patch) {
if (follower) {
follower.displayFormatId = patch.displayFormatId;
follower.displayFormat = DISPLAY_FORMATS[patch.displayFormatId] ?? DISPLAY_FORMATS[0];
} else {
leader.displayFormatId = patch.displayFormatId;
leader.displayFormat = DISPLAY_FORMATS[patch.displayFormatId] ?? DISPLAY_FORMATS[0];
}
}
return normalizePersonality({ channels: normalized }).channels;
}
export function addChannel(channels) {
const normalized = normalizePersonality({ channels }).channels;
normalized.push(ensureChannelDefaults({
channel: normalized.length + 1,
attributeId: 0,
home: 0,
displayFormatId: 0,
flags: []
}, normalized.length));
return normalizePersonality({ channels: normalized }).channels;
}
export function deleteEntry(channels, entryKey) {
const entries = buildVisibleEntries(channels);
const entry = entries.find((item) => item.key === entryKey);
if (!entry) return normalizePersonality({ channels }).channels;
const normalized = normalizePersonality({ channels }).channels;
normalized.splice(entry.startIndex, entry.isPair ? 2 : 1);
return normalizePersonality({ channels: normalized }).channels;
}
export function reorderEntries(channels, activeKey, targetKey, position = 'after') {
const normalized = normalizePersonality({ channels }).channels;
const entries = buildVisibleEntries(normalized);
const activeIndex = entries.findIndex((entry) => entry.key === activeKey);
const targetIndex = entries.findIndex((entry) => entry.key === targetKey);
if (activeIndex === -1 || targetIndex === -1 || activeIndex === targetIndex) return normalized;
const groups = entries.map((entry) => normalized.slice(entry.startIndex, entry.endIndex + 1));
const [moved] = groups.splice(activeIndex, 1);
const adjustedTargetIndex = activeIndex < targetIndex ? targetIndex - 1 : targetIndex;
const insertIndex = position === 'before' ? adjustedTargetIndex : adjustedTargetIndex + 1;
groups.splice(insertIndex, 0, moved);
return normalizePersonality({ channels: groups.flat() }).channels;
}
function mergeFlags(currentFlags = [], additions = []) {
return [...new Set([...currentFlags, ...additions])];
}

438
src/lib/server/db.js Normal file
View File

@@ -0,0 +1,438 @@
import Database from 'better-sqlite3';
import { env } from '$env/dynamic/private';
import { building } from '$app/environment';
let _db = null;
function getDb() {
if (_db) return _db;
const dbPath = env.DATABASE_URL ?? './dev.db';
_db = new Database(dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
_db.pragma('synchronous = NORMAL');
migrate(_db);
runStartupTasks(_db);
return _db;
}
function migrate(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS personalities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
prs_name TEXT,
file_name TEXT,
notes TEXT,
data BLOB NOT NULL,
manufacturer TEXT,
tags TEXT NOT NULL DEFAULT '[]',
channel_count INTEGER NOT NULL,
created_at TEXT NOT NULL,
creator_handle TEXT,
view_count INTEGER NOT NULL DEFAULT 0,
owner_token_hash TEXT NOT NULL,
deleted_at TEXT DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_manufacturer
ON personalities(manufacturer) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_created_at
ON personalities(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_view_count
ON personalities(view_count DESC) WHERE deleted_at IS NULL;
CREATE VIRTUAL TABLE IF NOT EXISTS personalities_fts
USING fts5(
id UNINDEXED,
name, manufacturer, notes, tags, creator_handle,
content=personalities, content_rowid=rowid
);
CREATE TRIGGER IF NOT EXISTS personalities_ai
AFTER INSERT ON personalities BEGIN
INSERT INTO personalities_fts(rowid, id, name, manufacturer, notes, tags, creator_handle)
VALUES (new.rowid, new.id, new.name, new.manufacturer, new.notes, new.tags, new.creator_handle);
END;
CREATE TRIGGER IF NOT EXISTS personalities_ad
AFTER DELETE ON personalities BEGIN
INSERT INTO personalities_fts(personalities_fts, rowid, id, name, manufacturer, notes, tags, creator_handle)
VALUES ('delete', old.rowid, old.id, old.name, old.manufacturer, old.notes, old.tags, old.creator_handle);
END;
-- Admins
CREATE TABLE IF NOT EXISTS admins (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
admin_id TEXT NOT NULL,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires
ON sessions(expires_at);
-- Reports
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
personality_id TEXT NOT NULL,
reason TEXT NOT NULL,
notes TEXT,
reporter_ip TEXT,
created_at TEXT NOT NULL,
resolved INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_reports_personality
ON reports(personality_id);
-- Contact messages
CREATE TABLE IF NOT EXISTS contact_messages (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT,
subject TEXT NOT NULL,
message TEXT NOT NULL,
sender_ip TEXT,
created_at TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_messages_read
ON contact_messages(read, created_at DESC);
`);
// Add columns to existing DBs that predate these migrations
const cols = db.prepare(`PRAGMA table_info(personalities)`).all().map(r => r.name);
if (!cols.includes('deleted_at')) {
db.exec(`ALTER TABLE personalities ADD COLUMN deleted_at TEXT DEFAULT NULL`);
}
if (!cols.includes('prs_name')) {
db.exec(`ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL`);
}
}
// Purge expired sessions and hard-delete soft-deleted personalities older than 60 days
function runStartupTasks(db) {
const now = new Date().toISOString();
db.prepare(`DELETE FROM sessions WHERE expires_at < ?`).run(now);
const cutoff = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
db.prepare(`DELETE FROM personalities WHERE deleted_at IS NOT NULL AND deleted_at < ?`).run(cutoff);
}
// ── Personality queries ──────────────────────────────────────────
export function insertPersonality(record) {
const db = getDb();
return db.prepare(`
INSERT INTO personalities
(id, name, prs_name, file_name, notes, data, manufacturer, tags,
channel_count, created_at, creator_handle, owner_token_hash)
VALUES
(@id, @name, @prs_name, @file_name, @notes, @data, @manufacturer, @tags,
@channel_count, @created_at, @creator_handle, @owner_token_hash)
`).run(record);
}
export function getPersonalityById(id) {
const db = getDb();
return db.prepare(`
SELECT id, name, prs_name, file_name, notes, manufacturer, tags,
channel_count, created_at, creator_handle, view_count, deleted_at
FROM personalities WHERE id = ?
`).get(id);
}
export function getPersonalityDataById(id) {
const db = getDb();
return db.prepare(`SELECT data FROM personalities WHERE id = ? AND deleted_at IS NULL`).get(id);
}
export function getPersonalityTokenHash(id) {
const db = getDb();
return db.prepare(`SELECT owner_token_hash FROM personalities WHERE id = ?`).get(id);
}
export function incrementViewCount(id) {
const db = getDb();
db.prepare(`UPDATE personalities SET view_count = view_count + 1 WHERE id = ? AND deleted_at IS NULL`).run(id);
}
// Hard delete (owner token path)
export function deletePersonality(id) {
const db = getDb();
db.prepare(`DELETE FROM personalities WHERE id = ?`).run(id);
}
export function updatePersonalityMeta(id, { name, prs_name, manufacturer, notes, tags, creator_handle }) {
const db = getDb();
db.prepare(`
UPDATE personalities SET
name = @name,
prs_name = @prs_name,
manufacturer = @manufacturer,
notes = @notes,
tags = @tags,
creator_handle = @creator_handle
WHERE id = @id
`).run({ id, name, prs_name, manufacturer, notes, tags, creator_handle });
}
export function replacePersonalityBinary(id, { data, prs_name, channel_count }) {
const db = getDb();
db.prepare(`
UPDATE personalities SET
data = @data,
prs_name = @prs_name,
channel_count = @channel_count
WHERE id = @id
`).run({ id, data, prs_name, channel_count });
}
export function softDeletePersonality(id) {
const db = getDb();
const now = new Date().toISOString();
db.prepare(`UPDATE personalities SET deleted_at = ? WHERE id = ?`).run(now, id);
// Resolve all open reports for this personality
db.prepare(`UPDATE reports SET resolved = 2 WHERE personality_id = ? AND resolved = 0`).run(id);
}
export function listPersonalities({ page = 1, limit = 24, sort = 'newest', manufacturer = '', q = '' } = {}) {
const db = getDb();
const offset = (page - 1) * limit;
const orderBy = sort === 'popular' ? 'view_count DESC' : 'created_at DESC';
if (q && q.trim().length > 0) {
const ftsQuery = q.trim().split(/\s+/).map(t => `"${t.replace(/"/g, '')}"`).join(' OR ');
const whereManufacturer = manufacturer ? `AND p.manufacturer = ?` : '';
const params = manufacturer ? [ftsQuery, manufacturer, limit, offset] : [ftsQuery, limit, offset];
const rows = db.prepare(`
SELECT p.id, p.name, p.prs_name, p.file_name, p.manufacturer, p.tags,
p.channel_count, p.created_at, p.creator_handle, p.view_count
FROM personalities_fts fts
JOIN personalities p ON p.rowid = fts.rowid
WHERE personalities_fts MATCH ? AND p.deleted_at IS NULL
${whereManufacturer}
ORDER BY ${orderBy} LIMIT ? OFFSET ?
`).all(...params);
const { total } = db.prepare(`
SELECT COUNT(*) as total
FROM personalities_fts fts
JOIN personalities p ON p.rowid = fts.rowid
WHERE personalities_fts MATCH ? AND p.deleted_at IS NULL
${whereManufacturer}
`).get(...params.slice(0, manufacturer ? 2 : 1));
return { rows, total };
}
const where = manufacturer
? `WHERE deleted_at IS NULL AND manufacturer = ?`
: `WHERE deleted_at IS NULL`;
const params = manufacturer ? [manufacturer, limit, offset] : [limit, offset];
const rows = db.prepare(`
SELECT id, name, prs_name, file_name, manufacturer, tags,
channel_count, created_at, creator_handle, view_count
FROM personalities ${where}
ORDER BY ${orderBy} LIMIT ? OFFSET ?
`).all(...params);
const { total } = db.prepare(
`SELECT COUNT(*) as total FROM personalities ${where}`
).get(...(manufacturer ? [manufacturer] : []));
return { rows, total };
}
export function getDistinctManufacturers() {
const db = getDb();
return db.prepare(`
SELECT DISTINCT manufacturer FROM personalities
WHERE manufacturer IS NOT NULL AND manufacturer != '' AND deleted_at IS NULL
ORDER BY manufacturer ASC
`).all().map(r => r.manufacturer);
}
export function getManufacturerCounts() {
const db = getDb();
const rows = db.prepare(`
SELECT manufacturer, COUNT(*) as count
FROM personalities
WHERE manufacturer IS NOT NULL AND manufacturer != '' AND deleted_at IS NULL
GROUP BY manufacturer
ORDER BY manufacturer ASC
`).all();
return Object.fromEntries(rows.map(r => [r.manufacturer, r.count]));
}
// ── Admin queries ────────────────────────────────────────────────
export function getAdminByUsername(username) {
const db = getDb();
return db.prepare(`SELECT * FROM admins WHERE username = ?`).get(username);
}
export function upsertAdmin({ id, username, password_hash, created_at }) {
const db = getDb();
return db.prepare(`
INSERT INTO admins (id, username, password_hash, created_at)
VALUES (@id, @username, @password_hash, @created_at)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash
`).run({ id, username, password_hash, created_at });
}
// ── Session queries ──────────────────────────────────────────────
export function createSession({ token, admin_id, username, created_at, expires_at }) {
const db = getDb();
return db.prepare(`
INSERT INTO sessions (token, admin_id, username, created_at, expires_at)
VALUES (@token, @admin_id, @username, @created_at, @expires_at)
`).run({ token, admin_id, username, created_at, expires_at });
}
export function getSession(token) {
const db = getDb();
return db.prepare(`SELECT * FROM sessions WHERE token = ? AND expires_at > ?`)
.get(token, new Date().toISOString());
}
export function deleteSession(token) {
const db = getDb();
db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token);
}
// ── Report queries ───────────────────────────────────────────────
export function insertReport({ id, personality_id, reason, notes, reporter_ip, created_at }) {
const db = getDb();
return db.prepare(`
INSERT INTO reports (id, personality_id, reason, notes, reporter_ip, created_at)
VALUES (@id, @personality_id, @reason, @notes, @reporter_ip, @created_at)
`).run({ id, personality_id, reason, notes, reporter_ip, created_at });
}
export function getOpenReportCount() {
const db = getDb();
return db.prepare(`SELECT COUNT(*) as count FROM reports WHERE resolved = 0`).get().count;
}
export function listReports({ resolved = null } = {}) {
const db = getDb();
const where = resolved === null ? '' : `WHERE r.resolved = ${resolved}`;
return db.prepare(`
SELECT r.*, p.name as personality_name, p.manufacturer, p.deleted_at
FROM reports r
LEFT JOIN personalities p ON p.id = r.personality_id
${where}
ORDER BY r.created_at DESC
LIMIT 200
`).all();
}
export function resolveReport(id, resolved = 1) {
const db = getDb();
db.prepare(`UPDATE reports SET resolved = ? WHERE id = ?`).run(resolved, id);
}
export function dismissAllReportsForPersonality(personality_id) {
const db = getDb();
db.prepare(`UPDATE reports SET resolved = 1 WHERE personality_id = ? AND resolved = 0`).run(personality_id);
}
// ── Admin dashboard stats ────────────────────────────────────────
export function getAdminStats() {
const db = getDb();
const total = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE deleted_at IS NULL`).get().n;
const deleted = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE deleted_at IS NOT NULL`).get().n;
const openReports = db.prepare(`SELECT COUNT(*) as n FROM reports WHERE resolved = 0`).get().n;
const todayCutoff = new Date(Date.now() - 24*60*60*1000).toISOString();
const today = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE created_at > ? AND deleted_at IS NULL`).get(todayCutoff).n;
const unreadMessages = db.prepare(`SELECT COUNT(*) as n FROM contact_messages WHERE read = 0`).get().n;
return { total, deleted, openReports, today, unreadMessages };
}
// ── Contact message queries ──────────────────────────────────────
export function insertContactMessage({ id, name, email, subject, message, sender_ip, created_at }) {
const db = getDb();
return db.prepare(`
INSERT INTO contact_messages (id, name, email, subject, message, sender_ip, created_at)
VALUES (@id, @name, @email, @subject, @message, @sender_ip, @created_at)
`).run({ id, name, email, subject, message, sender_ip, created_at });
}
export function listContactMessages({ unreadOnly = false } = {}) {
const db = getDb();
const where = unreadOnly ? `WHERE read = 0` : '';
return db.prepare(`
SELECT * FROM contact_messages ${where}
ORDER BY created_at DESC LIMIT 200
`).all();
}
export function markMessageRead(id) {
const db = getDb();
db.prepare(`UPDATE contact_messages SET read = 1 WHERE id = ?`).run(id);
}
export function markAllMessagesRead() {
const db = getDb();
db.prepare(`UPDATE contact_messages SET read = 1 WHERE read = 0`).run();
}
export function getUnreadMessageCount() {
const db = getDb();
return db.prepare(`SELECT COUNT(*) as count FROM contact_messages WHERE read = 0`).get().count;
}
export function listRecentPersonalitiesAdmin({ page = 1, limit = 25, q = '' } = {}) {
const db = getDb();
const offset = (page - 1) * limit;
if (q.trim()) {
const ftsQuery = q.trim().split(/\s+/).map(t => `"${t.replace(/"/g, '')}"`).join(' OR ');
const rows = db.prepare(`
SELECT p.id, p.name, p.manufacturer, p.channel_count, p.created_at,
p.creator_handle, p.view_count, p.deleted_at
FROM personalities_fts fts
JOIN personalities p ON p.rowid = fts.rowid
WHERE personalities_fts MATCH ?
ORDER BY p.created_at DESC LIMIT ? OFFSET ?
`).all(ftsQuery, limit, offset);
const { total } = db.prepare(`
SELECT COUNT(*) as total
FROM personalities_fts fts JOIN personalities p ON p.rowid = fts.rowid
WHERE personalities_fts MATCH ?
`).get(ftsQuery);
return { rows, total };
}
const rows = db.prepare(`
SELECT id, name, manufacturer, channel_count, created_at,
creator_handle, view_count, deleted_at
FROM personalities ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(limit, offset);
const { total } = db.prepare(`SELECT COUNT(*) as total FROM personalities`).get();
return { rows, total };
}
// Prevent DB from being instantiated during build
export const db = building ? null : { getDb };

View File

@@ -0,0 +1,43 @@
// Seed list for manufacturer autocomplete suggestions.
// Users can type any value — these appear as datalist suggestions.
// ETC family brands are listed first since this is an ETC PRS tool.
export const MANUFACTURER_SEEDS = [
// ETC Family
'ETC',
'High End Systems',
'Wybron',
// Major moving light manufacturers
'Martin',
'Robe',
'Clay Paky',
'Chauvet Professional',
'Elation Professional',
'GLP',
'Vari-Lite',
'Ayrton',
'ACME',
'Claypaky',
// LED / Blinder / Strobe
'ADJ',
'Chauvet DJ',
'Blizzard Lighting',
'CITC',
'Acclaim Lighting',
// Follow spots / Conventional
'Strong International',
'Robert Juliat',
'Lycian',
'Altman',
'Strand Lighting',
// Control / Other
'Pathway Connectivity',
'Philips Selecon',
'Leviton',
'Swisson',
'City Theatrical',
];

View File

@@ -0,0 +1,55 @@
import { env } from '$env/dynamic/private';
// In-memory store: Map<ip, { count, windowStart }>
const publishStore = new Map();
const readStore = new Map();
const PUBLISH_LIMIT = parseInt(env.RATE_LIMIT_PUBLISH ?? '5');
const READ_LIMIT = parseInt(env.RATE_LIMIT_READ ?? '100');
const PUBLISH_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const READ_WINDOW_MS = 60 * 1000; // 1 minute
function check(store, ip, limit, windowMs) {
const now = Date.now();
const entry = store.get(ip);
if (!entry || now - entry.windowStart > windowMs) {
store.set(ip, { count: 1, windowStart: now });
return { allowed: true, remaining: limit - 1 };
}
if (entry.count >= limit) {
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
return { allowed: false, remaining: 0, retryAfter };
}
entry.count += 1;
return { allowed: true, remaining: limit - entry.count };
}
export function checkPublishRate(ip) {
return check(publishStore, ip, PUBLISH_LIMIT, PUBLISH_WINDOW_MS);
}
export function checkReadRate(ip) {
return check(readStore, ip, READ_LIMIT, READ_WINDOW_MS);
}
export function getClientIp(request) {
return (
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
request.headers.get('x-real-ip') ??
'127.0.0.1'
);
}
// Cleanup old entries every 10 minutes to prevent memory leak
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of publishStore) {
if (now - entry.windowStart > PUBLISH_WINDOW_MS) publishStore.delete(ip);
}
for (const [ip, entry] of readStore) {
if (now - entry.windowStart > READ_WINDOW_MS) readStore.delete(ip);
}
}, 10 * 60 * 1000);

40
src/lib/server/session.js Normal file
View File

@@ -0,0 +1,40 @@
import { nanoid } from 'nanoid';
import { createSession, getSession, deleteSession } from './db.js';
export const SESSION_COOKIE = 'prs_admin_session';
const SESSION_DAYS = 7;
export function createAdminSession(admin) {
const token = nanoid(32);
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_DAYS * 24 * 60 * 60 * 1000);
createSession({
token,
admin_id: admin.id,
username: admin.username,
created_at: now.toISOString(),
expires_at: expiresAt.toISOString()
});
return { token, expiresAt };
}
export function verifySession(token) {
if (!token) return null;
return getSession(token) ?? null;
}
export function destroySession(token) {
if (token) deleteSession(token);
}
export function getSessionCookieOptions(expiresAt) {
return {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
expires: expiresAt
};
}

12
src/lib/shared/slugify.js Normal file
View File

@@ -0,0 +1,12 @@
/**
* Shared slug helper — safe for both server-side (+page.server.js) and
* client-side use. Avoids importing the full `slugify` package on the client.
*/
export function makeSlug(text) {
return String(text ?? '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'personality';
}

110
src/lib/storage.js Normal file
View File

@@ -0,0 +1,110 @@
import { browser } from '$app/environment';
import { cryptoLikeId } from '$lib/prs';
export const FILES_STORAGE_KEY = 'etc-prs-ui-open-files-v2';
export const SNAPSHOT_STORAGE_KEY = 'etc-prs-ui-history-v2';
function readList(key) {
if (!browser) return [];
try {
const raw = localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function writeList(key, value) {
if (!browser) return;
localStorage.setItem(key, JSON.stringify(value));
}
export function getStoredFiles() {
return readList(FILES_STORAGE_KEY);
}
export function saveStoredFile(personality, fileName = null, notes = null, sharedRecord = null) {
const files = getStoredFiles();
const signature = JSON.stringify({
name: personality.name,
channelCount: personality.channelCount,
channels: personality.channels?.map((channel) => ({
attributeId: channel.attributeId,
home: channel.home,
displayFormatId: channel.displayFormatId,
flags: channel.flags
}))
});
const now = new Date().toISOString();
const existing = files.find((file) => file.signature === signature);
const record = existing ?? {
id: cryptoLikeId('file'),
createdAt: now
};
record.name = personality.name || '(blank)';
record.fileName = fileName ?? record.fileName ?? null;
record.notes = notes !== null ? notes : (record.notes ?? '');
record.channelCount = personality.channelCount ?? personality.channels?.length ?? 0;
record.signature = signature;
record.parsed = personality;
record.lastOpenedAt = now;
// Persist share metadata if provided
if (sharedRecord) {
record.shared_id = sharedRecord.id;
record.shared_url = sharedRecord.url;
record.owner_token = sharedRecord.owner_token;
}
writeList(FILES_STORAGE_KEY, [record, ...files.filter((file) => file.id !== record.id)].slice(0, 25));
return record;
}
/**
* Attach share metadata to an existing local file record by its local id.
* Called after a successful publish so the token is stored alongside the file.
*/
export function attachSharedRecord(localFileId, sharedRecord) {
if (!browser) return;
const files = getStoredFiles();
const record = files.find((f) => f.id === localFileId);
if (!record) return;
record.shared_id = sharedRecord.id;
record.shared_url = sharedRecord.url;
record.owner_token = sharedRecord.owner_token;
writeList(FILES_STORAGE_KEY, [record, ...files.filter((f) => f.id !== localFileId)]);
}
/**
* Remove share metadata from any local file record matching a shared_id.
* Called after a successful library delete so the record no longer shows as published.
*/
export function detachSharedRecord(sharedId) {
if (!browser) return;
const files = getStoredFiles();
const updated = files.map(f => {
if (f.shared_id !== sharedId) return f;
const { shared_id, shared_url, owner_token, ...rest } = f;
return rest;
});
writeList(FILES_STORAGE_KEY, updated);
}
export function getSnapshots(fileId) {
return readList(SNAPSHOT_STORAGE_KEY).filter((snapshot) => snapshot.fileId === fileId);
}
export function saveSnapshot(fileId, personality, label = 'Manual Snapshot') {
const all = readList(SNAPSHOT_STORAGE_KEY);
const snapshot = {
id: cryptoLikeId('snap'),
fileId,
label,
createdAt: new Date().toISOString(),
parsed: personality
};
writeList(SNAPSHOT_STORAGE_KEY, [snapshot, ...all].slice(0, 100));
return snapshot;
}

View File

@@ -0,0 +1,13 @@
export function clickOutside(node, callback) {
function handle(event) {
if (!node.contains(event.target)) callback?.(event);
}
document.addEventListener('mousedown', handle);
return {
destroy() {
document.removeEventListener('mousedown', handle);
}
};
}

View File

@@ -0,0 +1,9 @@
<script>
import '../app.css';
import Footer from '$lib/components/Footer.svelte';
</script>
<div style="min-height:100vh; display:flex; flex-direction:column; width:100%;">
<slot />
<Footer />
</div>

945
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,945 @@
<script>
import { browser } from '$app/environment';
import { BookOpen, FileStack, AlertCircle, FolderOpen, Layers3, HardDriveUpload, Trash2, Table2 } from 'lucide-svelte';
import GlobalMenu from '$lib/components/GlobalMenu.svelte';
import SecondaryBar from '$lib/components/SecondaryBar.svelte';
import ChannelCardGrid from '$lib/components/ChannelCardGrid.svelte';
import ChannelTable from '$lib/components/ChannelTable.svelte';
import Toast from '$lib/components/Toast.svelte';
import PublishModal from '$lib/components/PublishModal.svelte';
import PublishSuccessModal from '$lib/components/PublishSuccessModal.svelte';
import ClearDataModal from '$lib/components/ClearDataModal.svelte';
import {
ATTRIBUTE_NAMES,
NAME_LEN,
addChannel,
buildPRS,
buildVisibleEntries,
createBlankPersonality,
deleteEntry,
normalizePersonality,
parsePRS,
reorderEntries,
toggle16BitPair,
updateEntry
} from '$lib/prs';
import { getSnapshots, getStoredFiles, saveSnapshot, saveStoredFile, attachSharedRecord, FILES_STORAGE_KEY, SNAPSHOT_STORAGE_KEY } from '$lib/storage';
import { makeSlug } from '$lib/shared/slugify.js';
import { cryptoLikeId } from '$lib/prs';
const VIEW_STORAGE_KEY = 'etc-prs-ui-view-mode-v1';
const AUTOSAVE_KEY = 'etc-prs-ui-autosave-v1';
const SCALE_STORAGE_KEY = 'etc-prs-ui-scale-v1';
const SCALE_MIN = 0.6;
const SCALE_MAX = 1.4;
const SCALE_STEP = 0.1;
const SCALE_DEFAULT = 1.0;
let mode = 'root';
let view = 'cards';
let currentFileId = null;
let personality = createBlankPersonality();
let displayName = ''; // unconstrained library/display name
let displayNameEdited = false; // true once user has manually changed it
let previousFiles = [];
let snapshots = [];
let draggingKey = null;
let dropTarget = null;
let error = '';
let showNewFileModal = false;
let newFileName = '';
let notes = '';
let showPublishModal = false;
let publishResult = null;
let showClearModal = false;
let latestItems = [];
let latestLoading = true;
async function fetchLatest() {
try {
const res = await fetch('/api/library?limit=5&sort=newest');
if (res.ok) {
const data = await res.json();
latestItems = data.items ?? [];
}
} catch { /* non-fatal — section just stays empty */ }
latestLoading = false;
}
if (browser) fetchLatest();
let uiScale = SCALE_DEFAULT;
let toastVisible = false;
let toastMessage = '';
let toastTimer;
function showToast(message) {
toastMessage = message;
toastVisible = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible = false; }, 2500);
}
$: normalized = normalizePersonality(personality);
$: visibleEntries = buildVisibleEntries(normalized.channels);
$: totalChannelCount = normalized.channels.length;
$: pairCount = visibleEntries.filter((entry) => entry.isPair).length;
function refreshStorageLists() {
if (!browser) return;
previousFiles = getStoredFiles();
snapshots = currentFileId ? getSnapshots(currentFileId) : [];
}
if (browser) {
view = localStorage.getItem(VIEW_STORAGE_KEY) || 'cards';
const savedScale = parseFloat(localStorage.getItem(SCALE_STORAGE_KEY));
if (!isNaN(savedScale)) uiScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, savedScale));
const autosaved = localStorage.getItem(AUTOSAVE_KEY);
if (autosaved) {
try {
const parsed = JSON.parse(autosaved);
personality = normalizePersonality(parsed.personality);
mode = parsed.mode ?? 'viewer';
currentFileId = parsed.currentFileId ?? null;
notes = parsed.notes ?? '';
} catch {
// ignore corrupt autosave
}
}
refreshStorageLists();
}
// On mount, enrich any token-only stubs from a previous data clear
if (browser) {
enrichTokenStubs();
}
$: if (browser) {
localStorage.setItem(VIEW_STORAGE_KEY, view);
}
$: if (browser) {
localStorage.setItem(SCALE_STORAGE_KEY, String(uiScale));
}
function scaleUp() { uiScale = Math.min(SCALE_MAX, +(uiScale + SCALE_STEP).toFixed(1)); }
function scaleDown() { uiScale = Math.max(SCALE_MIN, +(uiScale - SCALE_STEP).toFixed(1)); }
function scaleReset(){ uiScale = SCALE_DEFAULT; }
// Autosave personality state on every change (only when a file is active)
$: if (browser && mode !== 'root') {
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({ personality: normalized, mode, currentFileId, notes }));
}
async function handleOpenFile(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
try {
error = '';
const buffer = await file.arrayBuffer();
personality = normalizePersonality(parsePRS(buffer));
displayName = personality.name;
displayNameEdited = false;
const stored = saveStoredFile(personality, file.name);
currentFileId = stored.id;
notes = stored.notes ?? '';
mode = 'viewer';
refreshStorageLists();
} catch (err) {
error = err?.message ?? String(err);
} finally {
event.currentTarget.value = '';
}
}
async function openStoredFile(item) {
error = '';
if (item.shared_id && (!item.parsed?.channels?.length)) {
try {
const res = await fetch(`/api/personality/${item.shared_id}/download`);
if (res.ok) {
const buf = await res.arrayBuffer();
const parsed = normalizePersonality(parsePRS(buf));
saveStoredFile(parsed, item.fileName, item.notes ?? '');
const stored = saveStoredFile(parsed, item.fileName, item.notes ?? '');
personality = parsed;
displayName = item.displayName || parsed.name;
displayNameEdited = !!item.displayName && item.displayName !== parsed.name;
currentFileId = stored.id;
notes = item.notes ?? '';
mode = 'viewer';
refreshStorageLists();
return;
}
} catch { /* fall through */ }
}
personality = normalizePersonality(item.parsed);
displayName = item.displayName || personality.name;
displayNameEdited = !!item.displayName && item.displayName !== personality.name;
currentFileId = item.id;
notes = item.notes ?? '';
mode = 'viewer';
saveStoredFile(personality, item.fileName, item.notes ?? '');
refreshStorageLists();
}
function openSnapshot(item) {
personality = normalizePersonality(item.parsed);
mode = 'editor';
error = '';
}
function startNewFile() {
newFileName = '';
showNewFileModal = true;
}
function confirmNewFile() {
personality = { ...createBlankPersonality(), name: newFileName.slice(0, NAME_LEN) };
displayName = newFileName; // display name starts as full typed name (may exceed 12 chars)
displayNameEdited = false;
currentFileId = null;
notes = '';
mode = 'editor';
error = '';
showNewFileModal = false;
}
function cancelNewFile() {
showNewFileModal = false;
}
function enterEditor() {
mode = 'editor';
const stored = saveStoredFile(normalized);
currentFileId = stored.id;
refreshStorageLists();
}
function exitEditor() {
const stored = saveStoredFile(normalized);
currentFileId = stored.id;
mode = 'viewer';
refreshStorageLists();
}
function isBlankPersonality(p) {
// A file is considered blank if it has no name and no channels
return (!p.name || p.name.trim() === '') && (!p.channels || p.channels.length === 0);
}
function pruneCurrentIfBlank() {
if (currentFileId && isBlankPersonality(normalized)) {
const files = getStoredFiles();
const updated = files.filter(f => f.id !== currentFileId);
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
}
}
function goHome() {
pruneCurrentIfBlank();
mode = 'root';
currentFileId = null;
personality = createBlankPersonality();
displayName = '';
displayNameEdited = false;
notes = '';
if (browser) localStorage.removeItem(AUTOSAVE_KEY);
refreshStorageLists();
}
function handleDeleteFile() {
if (!confirm('Delete this file from local history? This cannot be undone.')) return;
if (currentFileId) {
const files = getStoredFiles();
const updated = files.filter(f => f.id !== currentFileId);
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
localStorage.removeItem(`${SNAPSHOT_STORAGE_KEY}:${currentFileId}`);
}
currentFileId = null;
personality = createBlankPersonality();
displayName = '';
displayNameEdited = false;
notes = '';
mode = 'root';
if (browser) localStorage.removeItem(AUTOSAVE_KEY);
refreshStorageLists();
showToast('File deleted from local history');
}
function updatePersonalityName(value) {
personality = { ...normalized, name: value.slice(0, NAME_LEN) };
// Keep display name in sync until user manually overrides it
if (!displayNameEdited) {
displayName = value;
}
}
function updateDisplayName(value) {
displayName = value;
displayNameEdited = value.trim() !== personality.name.trim();
}
function applyPatch(entryKey, patch) {
personality = {
...normalized,
channels: updateEntry(normalized.channels, entryKey, patch)
};
}
function handleTogglePair(entryKey, enabled) {
personality = {
...normalized,
channels: toggle16BitPair(normalized.channels, entryKey, enabled)
};
}
function handleAddChannel() {
personality = {
...normalized,
channels: addChannel(normalized.channels)
};
}
function handleDeleteEntry(entryKey) {
personality = {
...normalized,
channels: deleteEntry(normalized.channels, entryKey)
};
}
function handleDragStart(entryKey) {
draggingKey = entryKey;
}
function getDropPosition(targetKey, clientY) {
if (!browser) return 'after';
const element = document.querySelector(`[data-entry-key="${targetKey}"]`);
if (!element) return 'after';
const rect = element.getBoundingClientRect();
return clientY <= rect.top + rect.height / 2 ? 'before' : 'after';
}
function handleDrop(targetKey, clientY, commit = false) {
if (!draggingKey || draggingKey === targetKey) return;
const position = getDropPosition(targetKey, clientY);
dropTarget = { key: targetKey, position };
if (!commit) return;
personality = {
...normalized,
channels: reorderEntries(normalized.channels, draggingKey, targetKey, position)
};
draggingKey = null;
dropTarget = null;
}
function handleDragEnd() {
draggingKey = null;
dropTarget = null;
}
function handleClearData(keepTokens) {
if (!browser) return;
if (keepTokens) {
const files = getStoredFiles();
const tokens = files
.filter(f => f.shared_id && f.owner_token)
.map(f => ({ shared_id: f.shared_id, owner_token: f.owner_token, shared_url: f.shared_url }));
localStorage.removeItem(FILES_STORAGE_KEY);
localStorage.removeItem(SNAPSHOT_STORAGE_KEY);
localStorage.removeItem(AUTOSAVE_KEY);
if (tokens.length) {
// Write minimal stubs immediately so tokens aren't lost
const stubs = tokens.map(t => ({
id: cryptoLikeId('file'),
name: '(loading…)',
fileName: null,
notes: '',
channelCount: 0,
signature: '',
parsed: { name: '', channelCount: 0, channels: [] },
lastOpenedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
shared_id: t.shared_id,
shared_url: t.shared_url,
owner_token: t.owner_token,
}));
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(stubs));
// Then enrich stubs with real metadata from the library API
enrichTokenStubs();
}
} else {
localStorage.removeItem(FILES_STORAGE_KEY);
localStorage.removeItem(SNAPSHOT_STORAGE_KEY);
localStorage.removeItem(AUTOSAVE_KEY);
}
personality = createBlankPersonality();
currentFileId = null;
notes = '';
mode = 'root';
showClearModal = false;
refreshStorageLists();
showToast('Local data cleared');
}
// Fetch real metadata from the library API for any stub records
// (name === '(loading…)' or channelCount === 0 with a shared_id).
// Safe to call multiple times — skips already-enriched records.
async function enrichTokenStubs() {
if (!browser) return;
const files = getStoredFiles();
const stubs = files.filter(f => f.shared_id && f.channelCount === 0);
if (!stubs.length) return;
const enriched = await Promise.all(stubs.map(async stub => {
try {
const res = await fetch(`/api/personality/${stub.shared_id}`);
if (!res.ok) return stub;
const p = await res.json();
return {
...stub,
name: p.name || stub.name,
fileName: p.file_name || null,
channelCount: p.channel_count || 0,
notes: p.notes || '',
// Rebuild a minimal parsed object so "Load into editor" works after enrichment
parsed: {
name: p.name || '',
channelCount: p.channel_count || 0,
channels: []
}
};
} catch {
return stub;
}
}));
// Merge enriched stubs back into the full file list
const current = getStoredFiles();
const updated = current.map(f => {
const match = enriched.find(e => e.id === f.id);
return match ?? f;
});
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
refreshStorageLists();
}
function saveNotes(value) {
notes = value;
if (currentFileId) {
const files = getStoredFiles();
const record = files.find((f) => f.id === currentFileId);
if (record) {
saveStoredFile(record.parsed, record.fileName, value);
refreshStorageLists();
}
}
}
async function handlePublish({ manufacturer, tags, creator_handle, library_name }) {
const norm = normalizePersonality(personality);
const bytes = buildPRS(norm);
const res = await fetch('/api/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: Array.from(bytes),
name: library_name || displayName || norm.name,
file_name: personality.fileName ?? null,
notes: notes ?? '',
manufacturer,
tags,
creator_handle
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `Server error ${res.status}`);
}
const result = await res.json();
publishResult = result;
showPublishModal = false;
// Always save the file first so we have a guaranteed, current local record
// then attach the share metadata (token + URL) to that record's ID.
const stored = saveStoredFile(norm, personality.fileName ?? null, notes ?? null);
currentFileId = stored.id;
attachSharedRecord(stored.id, result);
refreshStorageLists();
}
function handleSaveSnapshot() {
const stored = saveStoredFile(normalized);
currentFileId = stored.id;
saveSnapshot(stored.id, normalized, `Snapshot ${new Date().toLocaleString()}`);
refreshStorageLists();
showToast('Snapshot saved');
}
function handleDownload() {
try {
const stored = saveStoredFile(normalized);
currentFileId = stored.id;
const bytes = buildPRS(normalized);
const blob = new Blob([bytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${(normalized.name || 'personality').replace(/[^A-Za-z0-9_-]/g, '_')}.prs`;
anchor.click();
URL.revokeObjectURL(url);
refreshStorageLists();
} catch (err) {
error = err?.message ?? String(err);
}
}
</script>
<svelte:head>
<title>ETC PRS Viewer & Editor</title>
</svelte:head>
<GlobalMenu
{previousFiles}
{snapshots}
{currentFileId}
currentMode={mode}
onOpenFile={handleOpenFile}
onNewFile={startNewFile}
onOpenStoredFile={openStoredFile}
onOpenSnapshot={openSnapshot}
onGoHome={goHome}
/>
<div style="width:100%; max-width:1400px; margin:0 auto; padding:20px; box-sizing:border-box;">
{#if error}
<div class="mb-4 flex items-start gap-3 p-4" style="border-radius:4px; border:1px solid rgba(248,113,113,0.35); background:var(--red-dim); color:var(--red);">
<AlertCircle class="mt-0.5 shrink-0" size={16} />
<div style="font-family:'DM Mono',monospace; font-size:12px;">{error}</div>
</div>
{/if}
{#if mode === 'root'}
<section class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
<div class="panel p-5">
<!-- Header -->
<div class="mb-5 flex items-center gap-3" style="border-bottom:1px solid var(--border); padding-bottom:16px;">
<BookOpen size={20} style="color:var(--amber); flex-shrink:0;" />
<div>
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">ETC PRS Viewer & Editor</h1>
<p class="subtle mt-0.5">Open a PRS file or start a blank personality.</p>
</div>
</div>
<!-- Mini stat strip -->
<div class="mb-5 grid grid-cols-3 gap-3">
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
<div class="label">Stored Files</div>
<div class="led-readout mt-1.5">{previousFiles.length}</div>
</div>
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
<div class="label">Snapshots</div>
<div class="led-readout cyan mt-1.5">{snapshots.length}</div>
</div>
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
<div class="label">Quick Start</div>
<div class="subtle mt-1.5">Use top bar to open files.</div>
</div>
</div>
<!-- Previously opened -->
<div>
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px; letter-spacing:0.12em; text-transform:uppercase; color:var(--text3); margin-bottom:10px;">Previously Opened</h2>
{#if previousFiles.length}
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{#each previousFiles as file}
<button
class="panel text-left transition"
style="padding:12px; display:block;"
type="button"
on:click={() => openStoredFile(file)}
on:mouseenter={(e) => { e.currentTarget.style.borderColor='var(--border2)'; }}
on:mouseleave={(e) => { e.currentTarget.style.borderColor='var(--border)'; }}
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px; letter-spacing:0.03em; color:var(--text);">{file.name}</div>
{#if file.fileName}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{file.fileName}</div>
{/if}
<div class="subtle mt-1">{file.channelCount} channels</div>
</div>
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:4px; flex-shrink:0;">
<FileStack size={16} style="color:var(--text3);" />
{#if file.shared_id}
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
letter-spacing:0.08em; text-transform:uppercase;
color:var(--cyan); border:1px solid rgba(45,212,200,0.3);
background:var(--cyan-dim); padding:1px 5px; border-radius:2px;">
Published
</span>
{/if}
</div>
</div>
{#if file.notes}
<div style="margin-top:6px; font-size:11px; color:var(--text3); font-style:italic; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{file.notes}</div>
{/if}
{#if file.shared_url}
<!-- Stop propagation so clicking the link doesn't also open the file -->
<a
href={file.shared_url}
target="_blank"
rel="noopener noreferrer"
style="display:inline-flex; align-items:center; gap:4px; margin-top:6px;
font-family:'DM Mono',monospace; font-size:10px; color:var(--cyan);
text-decoration:none;"
on:click|stopPropagation
>
View in library ↗
</a>
{/if}
<div class="subtle mt-2" style="font-family:'DM Mono',monospace; font-size:10px;">Last: {new Date(file.lastOpenedAt).toLocaleString()}</div>
</button>
{/each}
</div>
{:else}
<div class="subtle">No stored files yet.</div>
{/if}
</div>
</div>
<aside class="grid gap-3 content-start">
<!-- Latest Library Additions -->
<div class="panel" style="overflow:hidden;">
<div style="padding:12px 14px 10px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:8px;">
<div style="display:flex; align-items:center; gap:8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--amber); flex-shrink:0;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">Latest from Library</div>
</div>
<a href="/library" style="font-family:'Barlow Condensed',sans-serif; font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; color:var(--text3); text-decoration:none;"
on:mouseenter={(e) => e.currentTarget.style.color='var(--amber)'}
on:mouseleave={(e) => e.currentTarget.style.color='var(--text3)'}>
See all →
</a>
</div>
{#if latestLoading}
<div style="padding:20px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">Loading…</div>
{:else if latestItems.length === 0}
<div style="padding:20px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
No personalities published yet.<br/>Be the first!
</div>
{:else}
{#each latestItems as item, i}
<a
href="/p/{item.id}/{makeSlug(item.name)}"
style="display:block; padding:10px 14px; text-decoration:none;
border-bottom:{i < latestItems.length - 1 ? '1px solid var(--border)' : 'none'};
transition:background 0.1s;"
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background=''}
>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="min-width:0;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
letter-spacing:0.03em; color:var(--text); white-space:nowrap;
overflow:hidden; text-overflow:ellipsis;">
{item.name}
</div>
{#if item.manufacturer}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:1px;">
{item.manufacturer}
</div>
{/if}
</div>
<div style="font-family:'DM Mono',monospace; font-size:13px; font-weight:500;
color:{item.channel_count >= 12 ? 'var(--cyan)' : 'var(--amber)'};
flex-shrink:0; line-height:1.4;">
{item.channel_count}<span style="font-size:9px; color:var(--text3); margin-left:2px;">CH</span>
</div>
</div>
{#if item.tags?.length}
<div style="display:flex; flex-wrap:wrap; gap:3px; margin-top:5px;">
{#each item.tags.slice(0, 3) as tag}
<span class="badge" style="font-size:10px; padding:1px 5px;">{tag}</span>
{/each}
{#if item.tags.length > 3}
<span style="font-size:10px; color:var(--text3); font-family:'DM Mono',monospace;">+{item.tags.length - 3}</span>
{/if}
</div>
{/if}
</a>
{/each}
{/if}
</div>
<!-- Info cards -->
<div class="panel p-4">
<div class="flex items-center gap-2 mb-2">
<FolderOpen size={15} style="color:var(--amber);" />
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Opening files</div>
</div>
<p class="subtle">Use the <span style="color:var(--text);">File ▾</span> menu in the top bar to open a PRS from disk, start a new personality, or reopen a recent file.</p>
</div>
<div class="panel p-4">
<div class="flex items-center gap-2 mb-2">
<HardDriveUpload size={15} style="color:var(--amber);" />
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Local history</div>
</div>
<p class="subtle">Files and snapshots are saved in your browser. Use <span style="color:var(--text);">Delete</span> in the viewer or editor to remove a file from history.</p>
</div>
<div class="panel p-4">
<div class="flex items-center gap-2 mb-2">
<Table2 size={15} style="color:var(--amber);" />
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Two views</div>
</div>
<p class="subtle">Card view for editing and detail. Table view for a compact overview. Toggle in the toolbar — preference is remembered.</p>
</div>
<!-- Clear local data -->
<div class="panel p-4" style="border-color:var(--border);">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text3); flex-shrink:0;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Local data</div>
</div>
</div>
<p class="subtle" style="margin-bottom:10px;">Clear your file history, snapshots, and autosave from this browser.</p>
<button
class="btn btn-danger"
style="width:100%; justify-content:center; font-size:12px;"
type="button"
on:click={() => showClearModal = true}
>
Clear local data…
</button>
</div>
</aside>
</section>
{:else}
<section class="panel mb-4" style="padding:0; overflow:hidden;">
<!-- Name fields + stat cells -->
<div class="grid gap-0" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr) repeat(3,130px); border-bottom:1px solid var(--border);">
<!-- PRS Name (12-char binary limit) -->
<div style="padding:12px 16px; border-right:1px solid var(--border);">
<label class="label" for="fixtureName" style="display:flex; align-items:center; gap:6px;">
PRS Name
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
text-transform:none; letter-spacing:0; color:var(--text3);">
({NAME_LEN} char max)
</span>
</label>
<input
id="fixtureName"
class="input mt-1.5"
style="font-family:'DM Mono',monospace; font-size:15px; font-weight:600; letter-spacing:0.04em;"
type="text"
maxlength={NAME_LEN}
bind:value={personality.name}
on:input={(e) => updatePersonalityName(e.currentTarget.value)}
/>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px; text-align:right;">
{personality.name.length}/{NAME_LEN}
</div>
</div>
<!-- Display / Library Name (unconstrained) -->
<div style="padding:12px 16px; border-right:1px solid var(--border);">
<label class="label" for="displayName" style="display:flex; align-items:center; gap:6px;">
Library Name
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
text-transform:none; letter-spacing:0; color:var(--text3);">
(used when publishing)
</span>
</label>
<input
id="displayName"
class="input mt-1.5"
style="font-family:'Barlow Condensed',sans-serif; font-size:16px; font-weight:700; letter-spacing:0.04em;"
type="text"
maxlength="120"
bind:value={displayName}
placeholder={personality.name || 'Display name…'}
on:input={(e) => updateDisplayName(e.currentTarget.value)}
/>
{#if !displayNameEdited && personality.name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px;">
Auto-synced from PRS name
</div>
{/if}
</div>
<div style="padding:12px 16px; border-right:1px solid var(--border); display:flex; flex-direction:column; gap:4px;">
<div class="label">Channels</div>
<div class="led-readout">{totalChannelCount}</div>
</div>
<div style="padding:12px 16px; border-right:1px solid var(--border); display:flex; flex-direction:column; gap:4px;">
<div class="label">Entries</div>
<div class="led-readout cyan">{visibleEntries.length}</div>
</div>
<div style="padding:12px 16px; display:flex; flex-direction:column; gap:4px;">
<div class="label">16-bit Pairs</div>
<div class="led-readout green">{pairCount}</div>
</div>
</div>
<!-- Notes row -->
<div style="padding:10px 16px;">
<label class="label" for="fileNotes" style="display:inline-flex; align-items:center; gap:6px;">
Notes
<span style="font-family:'DM Mono',monospace; font-size:10px; letter-spacing:0.04em; color:var(--text3); text-transform:none; font-weight:400;">(not exported to PRS)</span>
</label>
<textarea
id="fileNotes"
class="input mt-1.5 resize-none"
style="font-size:14px; line-height:1.5;"
rows="2"
placeholder="Add any notes about this personality file…"
value={notes}
on:change={(e) => saveNotes(e.currentTarget.value)}
></textarea>
</div>
</section>
{/if}
</div>
{#if mode !== 'root'}
<SecondaryBar
{mode}
{view}
{uiScale}
{SCALE_MIN}
{SCALE_MAX}
onViewChange={(next) => (view = next)}
onOpenEditor={enterEditor}
onExitEditor={exitEditor}
onPublish={() => (showPublishModal = true)}
onAddChannel={handleAddChannel}
onSaveSnapshot={handleSaveSnapshot}
onDownload={handleDownload}
onDelete={handleDeleteFile}
onScaleUp={scaleUp}
onScaleDown={scaleDown}
onScaleReset={scaleReset}
/>
<div style="width:100%; max-width:1400px; margin:0 auto; padding:0 20px 20px; box-sizing:border-box;">
<div style="font-size:{uiScale}rem;">
{#if view === 'cards'}
<ChannelCardGrid
entries={visibleEntries}
editable={mode === 'editor'}
attributes={ATTRIBUTE_NAMES}
{draggingKey}
{dropTarget}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onUpdate={applyPatch}
onDelete={handleDeleteEntry}
onTogglePair={handleTogglePair}
/>
{:else}
<ChannelTable
entries={visibleEntries}
editable={mode === 'editor'}
attributes={ATTRIBUTE_NAMES}
{draggingKey}
{dropTarget}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onUpdate={applyPatch}
onDelete={handleDeleteEntry}
onTogglePair={handleTogglePair}
/>
{/if}
</div>
</div>
{/if}
<Toast message={toastMessage} visible={toastVisible} />
{#if showNewFileModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
style="background:rgba(0,0,0,0.75);"
role="presentation"
on:click|self={cancelNewFile}
on:keydown={(e) => { if (e.key === 'Escape') cancelNewFile(); }}
>
<div
class="panel w-full max-w-sm"
style="padding:24px; box-shadow:0 0 0 1px rgba(232,147,10,0.15), 0 24px 64px rgba(0,0,0,0.8);"
role="dialog"
aria-modal="true"
aria-labelledby="new-file-title"
>
<!-- Title bar -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border);">
<div style="width:4px; height:20px; background:var(--amber); border-radius:2px; box-shadow:0 0 8px rgba(232,147,10,0.5);"></div>
<h2 id="new-file-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">New Personality File</h2>
</div>
<p class="subtle mb-4">Give your fixture a name to get started. You can change it later.</p>
<label class="label" for="new-file-name">Fixture Name</label>
<!-- svelte-ignore a11y-autofocus -->
<input
id="new-file-name"
class="input mt-2"
style="font-family:'Barlow Condensed',sans-serif; font-size:15px; font-weight:700; letter-spacing:0.04em;"
type="text"
maxlength={NAME_LEN}
placeholder="e.g. MyFixture"
bind:value={newFileName}
autofocus
on:keydown={(e) => { if (e.key === 'Enter') confirmNewFile(); if (e.key === 'Escape') cancelNewFile(); }}
/>
<div class="mt-5 flex justify-end gap-2">
<button class="btn" type="button" on:click={cancelNewFile}>Cancel</button>
<button class="btn btn-primary" type="button" on:click={confirmNewFile}>Create File</button>
</div>
</div>
</div>
{/if}
{#if showPublishModal}
<PublishModal
personality={normalized}
{displayName}
onConfirm={handlePublish}
onCancel={() => (showPublishModal = false)}
/>
{/if}
{#if publishResult}
<PublishSuccessModal
result={publishResult}
onDone={() => (publishResult = null)}
/>
{/if}
{#if showClearModal}
<ClearDataModal
fileCount={previousFiles.length}
snapshotCount={snapshots.length}
onConfirm={handleClearData}
onCancel={() => (showClearModal = false)}
/>
{/if}

View File

@@ -0,0 +1,176 @@
<svelte:head>
<title>About — ETC PRS Editor</title>
</svelte:head>
<!-- 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(--amber);
box-shadow:0 0 10px rgba(232,147,10,0.6); 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(--cyan); opacity:0.8; text-transform:uppercase;">About</div>
</div>
</div>
<div style="flex:1;"></div>
<a class="btn" href="/" style="text-decoration:none;">← App</a>
<a class="btn" href="/library" style="text-decoration:none;">Library</a>
</div>
</div>
<div style="max-width:860px; margin:0 auto; padding:40px 20px;">
<!-- Hero -->
<div style="margin-bottom:40px;">
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
What is this?
</div>
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:40px;
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
line-height:1.05; margin-bottom:16px;">
ETC PRS<br/>Viewer & Editor
</h1>
<p style="font-size:16px; color:var(--text2); line-height:1.7; max-width:600px;">
A web-based tool for inspecting, editing, and sharing ETC Expression
fixture personality files — right in your browser, with no installation required.
</p>
</div>
<!-- Divider -->
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
<!-- Features grid -->
<div style="margin-bottom:40px;">
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
margin-bottom:20px;">
Features
</h2>
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:12px;">
{#each features as f}
<div class="panel" style="padding:16px 18px;">
<div style="font-family:'DM Mono',monospace; font-size:18px; margin-bottom:10px;
color:var(--amber);">{f.icon}</div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.05em; text-transform:uppercase; color:var(--text);
margin-bottom:6px;">{f.title}</div>
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{f.body}</p>
</div>
{/each}
</div>
</div>
<!-- Divider -->
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
<!-- 16-bit detail -->
<div style="margin-bottom:40px;">
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
margin-bottom:14px;">
16-bit Channel Handling
</h2>
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
16-bit channel pairs are detected strictly from the binary flag in the PRS file —
no heuristics, no guessing based on channel numbering. The leader channel carries
the <code style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
background:var(--raised); padding:1px 5px; border-radius:2px;">0x04</code> flag bit,
and the following channel is treated as its pair.
</p>
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
Home and display format values follow ETC's own storage convention — stored in the
second (follower) channel — so exported files are binary-compatible with ETC's
Personality Editor.
</p>
</div>
<!-- Divider -->
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
<!-- Data & privacy -->
<div style="margin-bottom:40px;">
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
margin-bottom:14px;">
Privacy & Data
</h2>
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px;">
{#each privacyPoints as point}
<div style="display:flex; gap:10px; align-items:flex-start; padding:12px 14px;
border-radius:3px; border:1px solid var(--border); background:var(--raised);">
<span style="color:var(--green); flex-shrink:0; font-size:14px;"></span>
<span style="font-size:13px; color:var(--text2); line-height:1.5;">{point}</span>
</div>
{/each}
</div>
</div>
<!-- Disclosures link -->
<div style="padding:16px 20px; border-radius:3px; border:1px solid rgba(232,147,10,0.2);
background:var(--amber-dim); display:flex; align-items:center;
justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--amber);
margin-bottom:3px;">Ethical Disclosures</div>
<div style="font-size:13px; color:var(--text2);">
AI usage, reverse engineering methodology, and affiliation statement.
</div>
</div>
<a class="btn" href="/disclosures" style="text-decoration:none; flex-shrink:0;">
Read disclosures →
</a>
</div>
</div>
<script>
const features = [
{
icon: '◈',
title: 'Open & View',
body: 'Load any .prs file directly from your machine. Card or table layout, accurate parsing from the binary structure.'
},
{
icon: '✎',
title: 'Edit Personalities',
body: 'Modify attributes, flags, and home values. Supports Independent, LTP, 16-bit, and Flipped channel modes.'
},
{
icon: '⇅',
title: 'Drag & Drop Reorder',
body: '16-bit pairs move as a single unit. Drag handles prevent accidental reorders while editing inputs.'
},
{
icon: '⬡',
title: 'Export PRS',
body: 'Export binary-compatible .prs files from the viewer or editor. Output matches ETC Personality Editor structure.'
},
{
icon: '◎',
title: 'Snapshots',
body: 'Save named checkpoints while editing and restore them at any time. Stored locally in your browser.'
},
{
icon: '⊕',
title: 'Public Library',
body: 'Browse and share personalities with the community. Search by fixture name, manufacturer, or tags.'
},
];
const privacyPoints = [
'All file handling is local to your machine',
'No data is transmitted externally when using the editor',
'Published personalities are shared only when you explicitly choose to publish',
'Local file history and snapshots live in your browser\'s localStorage',
'No tracking, analytics, or advertising',
];
</script>

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 -->

View File

@@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import { softDeletePersonality, getPersonalityById } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function POST({ request, cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const { id } = await request.json().catch(() => ({}));
if (!id) throw error(400, 'Missing personality id');
const record = getPersonalityById(id);
if (!record) throw error(404, 'Personality not found');
if (record.deleted_at) throw error(400, 'Already deleted');
softDeletePersonality(id);
return json({ success: true });
}

View File

@@ -0,0 +1,14 @@
import { json, error } from '@sveltejs/kit';
import { resolveReport } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function POST({ request, cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const { id } = await request.json().catch(() => ({}));
if (!id) throw error(400, 'Missing report id');
resolveReport(id, 1);
return json({ success: true });
}

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import { getPersonalityById, updatePersonalityMeta } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function PATCH({ request, cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const body = await request.json().catch(() => null);
if (!body?.id) throw error(400, 'Missing id');
const record = getPersonalityById(body.id);
if (!record) throw error(404, 'Personality not found');
if (record.deleted_at) throw error(400, 'Cannot edit a deleted personality');
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 120) : record.name;
const prs_name = typeof body.prs_name === 'string' ? body.prs_name.trim().slice(0, 12) : record.prs_name;
const manufacturer = typeof body.manufacturer === 'string' ? body.manufacturer.trim().slice(0, 128) : record.manufacturer;
const notes = typeof body.notes === 'string' ? body.notes.trim().slice(0, 1000) : record.notes;
const creator_handle = typeof body.creator_handle === 'string' ? body.creator_handle.trim().slice(0, 64) : record.creator_handle;
const rawTags = Array.isArray(body.tags) ? body.tags : JSON.parse(record.tags ?? '[]');
const tags = JSON.stringify(
rawTags.slice(0, 10).map(t => String(t).trim().toLowerCase().slice(0, 32)).filter(Boolean)
);
if (!name) throw error(400, 'Library name is required');
updatePersonalityMeta(body.id, { name, prs_name, manufacturer, notes, tags, creator_handle });
return json({ success: true });
}

View File

@@ -0,0 +1,20 @@
import { json, error } from '@sveltejs/kit';
import { markMessageRead, markAllMessagesRead } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function POST({ request, cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const { id, all } = await request.json().catch(() => ({}));
if (all) {
markAllMessagesRead();
} else if (id) {
markMessageRead(id);
} else {
throw error(400, 'Missing id or all flag');
}
return json({ success: true });
}

View File

@@ -0,0 +1,13 @@
import { json, error } from '@sveltejs/kit';
import { listContactMessages } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function GET({ cookies, url }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const unreadOnly = url.searchParams.get('unread') === '1';
const messages = listContactMessages({ unreadOnly });
return json({ messages });
}

View File

@@ -0,0 +1,61 @@
import { json, error } from '@sveltejs/kit';
import { getPersonalityById, replacePersonalityBinary } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
import { FILE_SIZE } from '$lib/prs.js';
const NAME_OFFSET = 0x0e;
const NAME_LEN = 12;
const CH_COUNT_OFFSET = 0x0d;
function readPrsName(bytes) {
let name = '';
for (let i = 0; i < NAME_LEN; i++) {
if (bytes[NAME_OFFSET + i] === 0) break;
name += String.fromCharCode(bytes[NAME_OFFSET + i]);
}
return name.trim();
}
export async function POST({ request, cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) throw error(401, 'Unauthorized');
const body = await request.json().catch(() => null);
if (!body?.id || !body?.data) throw error(400, 'Missing id or data');
const record = getPersonalityById(body.id);
if (!record) throw error(404, 'Personality not found');
if (record.deleted_at) throw error(400, 'Cannot replace binary of a deleted personality');
const bytes = new Uint8Array(body.data);
if (bytes.length !== FILE_SIZE) {
throw error(400, `Invalid file size: expected ${FILE_SIZE} bytes, got ${bytes.length}`);
}
const newPrsName = readPrsName(bytes);
const newChannelCount = bytes[CH_COUNT_OFFSET];
// Preview mode — return diff without saving
if (body.preview) {
return json({
preview: true,
current: {
prs_name: record.prs_name ?? '',
channel_count: record.channel_count
},
incoming: {
prs_name: newPrsName,
channel_count: newChannelCount
}
});
}
// Commit — replace the binary
replacePersonalityBinary(body.id, {
data: Buffer.from(bytes),
prs_name: newPrsName,
channel_count: newChannelCount
});
return json({ success: true, prs_name: newPrsName, channel_count: newChannelCount });
}

View File

@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { getOpenReportCount, getUnreadMessageCount } from '$lib/server/db.js';
import { verifySession, SESSION_COOKIE } from '$lib/server/session.js';
export async function GET({ cookies }) {
const session = verifySession(cookies.get(SESSION_COOKIE));
if (!session) {
return json({ count: null });
}
const reports = getOpenReportCount();
const messages = getUnreadMessageCount();
return json({ count: reports + messages, reports, messages });
}

View File

@@ -0,0 +1,55 @@
import { json, error } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import { insertContactMessage } from '$lib/server/db.js';
import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js';
const MIN_ELAPSED_MS = 4000; // reject if form submitted in under 4 seconds
const MAX_NAME_LEN = 100;
const MAX_EMAIL_LEN = 254;
const MAX_SUBJECT_LEN = 200;
const MAX_MESSAGE_LEN = 3000;
const SUBJECT_OPTIONS = [
'General question',
'Bug report',
'Feature request',
'Library content issue',
'Other',
];
export async function POST({ request }) {
const ip = getClientIp(request);
const rate = checkPublishRate(ip);
if (!rate.allowed) throw error(429, 'Too many submissions. Please try again later.');
let body;
try { body = await request.json(); }
catch { throw error(400, 'Invalid request.'); }
const { name, email, subject, message, _hp, _ts } = body;
// Honeypot — if filled in, silently accept but don't store
if (_hp) return json({ success: true }, { status: 201 });
// Timing check — reject suspiciously fast submissions
const elapsed = Date.now() - Number(_ts ?? 0);
if (elapsed < MIN_ELAPSED_MS) return json({ success: true }, { status: 201 });
// Validate required fields
if (!subject || !SUBJECT_OPTIONS.includes(subject)) throw error(400, 'Please select a subject.');
if (!message || typeof message !== 'string' || message.trim().length < 10) {
throw error(400, 'Message must be at least 10 characters.');
}
insertContactMessage({
id: nanoid(10),
name: typeof name === 'string' ? name.trim().slice(0, MAX_NAME_LEN) : null,
email: typeof email === 'string' ? email.trim().slice(0, MAX_EMAIL_LEN) : null,
subject: subject.slice(0, MAX_SUBJECT_LEN),
message: message.trim().slice(0, MAX_MESSAGE_LEN),
sender_ip: ip,
created_at: new Date().toISOString(),
});
return json({ success: true }, { status: 201 });
}

View File

@@ -0,0 +1,38 @@
import { json, error } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import { getPersonalityTokenHash, deletePersonality, getPersonalityById } from '$lib/server/db.js';
import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js';
export async function DELETE({ request }) {
// Reuse publish rate limit for destructive actions
const ip = getClientIp(request);
const rate = checkPublishRate(ip);
if (!rate.allowed) {
throw error(429, `Too many requests. Try again in ${rate.retryAfter} seconds.`);
}
let body;
try {
body = await request.json();
} catch {
throw error(400, 'Invalid JSON body.');
}
const { id, owner_token } = body;
if (!id || typeof id !== 'string') throw error(400, 'Missing id.');
if (!owner_token || typeof owner_token !== 'string') throw error(400, 'Missing owner_token.');
const meta = getPersonalityById(id);
if (!meta) throw error(404, 'Personality not found.');
const tokenRow = getPersonalityTokenHash(id);
if (!tokenRow) throw error(404, 'Personality not found.');
const valid = await bcrypt.compare(owner_token, tokenRow.owner_token_hash);
if (!valid) throw error(403, 'Invalid owner token.');
deletePersonality(id);
return json({ success: true });
}

View File

@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import { listPersonalities } from '$lib/server/db.js';
import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js';
export async function GET({ request, url }) {
const ip = getClientIp(request);
const rate = checkReadRate(ip);
if (!rate.allowed) {
return json({ error: 'Rate limit exceeded.' }, { status: 429 });
}
const q = url.searchParams.get('q') ?? '';
const manufacturer = url.searchParams.get('manufacturer') ?? '';
const sort = url.searchParams.get('sort') ?? 'newest';
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
const limit = Math.min(48, Math.max(1, parseInt(url.searchParams.get('limit') ?? '24')));
const { rows, total } = listPersonalities({ page, limit, sort, manufacturer, q });
// Parse tags JSON for each row
const items = rows.map(row => ({
...row,
tags: tryParseJson(row.tags, [])
}));
return json({
items,
total,
page,
pages: Math.ceil(total / limit),
limit
});
}
function tryParseJson(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}

View File

@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { getDistinctManufacturers } from '$lib/server/db.js';
import { MANUFACTURER_SEEDS } from '$lib/server/manufacturers.js';
export async function GET() {
const fromDb = getDistinctManufacturers();
// Merge DB values with seed list, deduplicate, sort
const all = [...new Set([...MANUFACTURER_SEEDS, ...fromDb])].sort((a, b) =>
a.localeCompare(b)
);
return json({ manufacturers: all });
}

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import { getPersonalityById, incrementViewCount } from '$lib/server/db.js';
import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js';
export async function GET({ request, params }) {
const ip = getClientIp(request);
const rate = checkReadRate(ip);
if (!rate.allowed) {
return json({ error: 'Rate limit exceeded.' }, { status: 429 });
}
const record = getPersonalityById(params.id);
if (!record) throw error(404, 'Personality not found.');
incrementViewCount(params.id);
return json({
...record,
tags: tryParseJson(record.tags, [])
});
}
function tryParseJson(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}

View File

@@ -0,0 +1,30 @@
import { error } from '@sveltejs/kit';
import { getPersonalityById, getPersonalityDataById } from '$lib/server/db.js';
import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js';
export async function GET({ request, params }) {
const ip = getClientIp(request);
const rate = checkReadRate(ip);
if (!rate.allowed) {
return new Response('Rate limit exceeded.', { status: 429 });
}
const meta = getPersonalityById(params.id);
if (!meta) throw error(404, 'Personality not found.');
const row = getPersonalityDataById(params.id);
if (!row) throw error(404, 'Personality data not found.');
const safeName = (meta.file_name ?? meta.name ?? 'personality')
.replace(/[^a-zA-Z0-9_\-. ]/g, '_')
.replace(/\.prs$/i, '');
return new Response(row.data, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${safeName}.prs"`,
'Content-Length': String(row.data.length)
}
});
}

View File

@@ -0,0 +1,35 @@
import { json, error } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import { insertReport, getPersonalityById } from '$lib/server/db.js';
import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js';
const VALID_REASONS = ['incorrect-data', 'duplicate', 'inappropriate', 'spam', 'other'];
export async function POST({ request }) {
const ip = getClientIp(request);
const rate = checkPublishRate(ip);
if (!rate.allowed) throw error(429, `Too many reports. Try again later.`);
let body;
try { body = await request.json(); }
catch { throw error(400, 'Invalid JSON'); }
const { personality_id, reason, notes } = body;
if (!personality_id || typeof personality_id !== 'string') throw error(400, 'Missing personality_id');
if (!VALID_REASONS.includes(reason)) throw error(400, 'Invalid reason');
const record = getPersonalityById(personality_id);
if (!record || record.deleted_at) throw error(404, 'Personality not found');
insertReport({
id: nanoid(10),
personality_id,
reason,
notes: typeof notes === 'string' ? notes.trim().slice(0, 500) : null,
reporter_ip: ip,
created_at: new Date().toISOString()
});
return json({ success: true }, { status: 201 });
}

View File

@@ -0,0 +1,98 @@
import { json, error } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { insertPersonality } from '$lib/server/db.js';
import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js';
import { makeSlug } from '$lib/shared/slugify.js';
import { FILE_SIZE } from '$lib/prs.js';
import { env } from '$env/dynamic/public';
const MAX_HANDLE_LEN = 64;
const MAX_NOTES_LEN = 1000;
const MAX_TAGS = 10;
const MAX_TAG_LEN = 32;
export async function POST({ request }) {
// Rate limiting
const ip = getClientIp(request);
const rate = checkPublishRate(ip);
if (!rate.allowed) {
throw error(429, `Too many uploads. Try again in ${rate.retryAfter} seconds.`);
}
let body;
try {
body = await request.json();
} catch {
throw error(400, 'Invalid JSON body.');
}
const { data, name, library_name, file_name, notes, manufacturer, tags, creator_handle } = body;
// Validate binary data
if (!data || !Array.isArray(data)) {
throw error(400, 'Missing or invalid PRS data.');
}
const bytes = new Uint8Array(data);
if (bytes.length !== FILE_SIZE) {
throw error(400, `PRS file must be exactly ${FILE_SIZE} bytes (got ${bytes.length}).`);
}
// prs_name is the raw 12-char binary name; library_name is the full display name
if (!name || typeof name !== 'string') {
throw error(400, 'Missing fixture name.');
}
const prsName = name.trim().slice(0, 12);
const displayName = typeof library_name === 'string' && library_name.trim()
? library_name.trim().slice(0, 120)
: prsName; // fall back to PRS name if no display name provided
// Sanitize optional fields
const cleanNotes = typeof notes === 'string' ? notes.slice(0, MAX_NOTES_LEN) : '';
const cleanHandle = typeof creator_handle === 'string'
? creator_handle.trim().slice(0, MAX_HANDLE_LEN)
: '';
const cleanManufacturer = typeof manufacturer === 'string'
? manufacturer.trim().slice(0, 128)
: '';
// Validate tags
const cleanTags = Array.isArray(tags)
? tags.slice(0, MAX_TAGS).map(t => String(t).trim().toLowerCase().slice(0, MAX_TAG_LEN)).filter(Boolean)
: [];
// Channel count from the binary
const channelCount = bytes[0x0d];
// Generate ID and owner token
const id = nanoid(10);
const slug = makeSlug(displayName);
const rawToken = nanoid(32);
const tokenHash = await bcrypt.hash(rawToken, 10);
const createdAt = new Date().toISOString();
insertPersonality({
id,
name: displayName,
prs_name: prsName,
file_name: typeof file_name === 'string' ? file_name.slice(0, 256) : null,
notes: cleanNotes,
data: Buffer.from(bytes),
manufacturer: cleanManufacturer,
tags: JSON.stringify(cleanTags),
channel_count: channelCount,
created_at: createdAt,
creator_handle: cleanHandle || null,
owner_token_hash: tokenHash
});
const baseUrl = env.PUBLIC_BASE_URL ?? '';
return json({
id,
slug,
url: `${baseUrl}/p/${id}/${slug}`,
owner_token: rawToken
}, { status: 201 });
}

View File

@@ -0,0 +1,209 @@
<script>
import { onMount } from 'svelte';
const SUBJECT_OPTIONS = [
'General question',
'Bug report',
'Feature request',
'Library content issue',
'Other',
];
let name = '';
let email = '';
let subject = '';
let message = '';
let honeypot = ''; // must stay empty
let startTime = 0; // set on mount for timing check
let submitting = false;
let submitted = false;
let errorMsg = '';
onMount(() => { startTime = Date.now(); });
async function handleSubmit(e) {
e.preventDefault();
if (submitting) return;
errorMsg = '';
if (!subject) { errorMsg = 'Please select a subject.'; return; }
if (message.trim().length < 10) { errorMsg = 'Please write a bit more — at least 10 characters.'; return; }
submitting = true;
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
email: email.trim(),
subject,
message: message.trim(),
_hp: honeypot, // honeypot
_ts: startTime, // timestamp for timing check
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `Error ${res.status}`);
}
submitted = true;
} catch (err) {
errorMsg = err.message;
submitting = false;
}
}
</script>
<svelte:head>
<title>Contact — ETC PRS Editor</title>
</svelte:head>
<!-- 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(--amber);
box-shadow:0 0 10px rgba(232,147,10,0.6); 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(--cyan); opacity:0.8; text-transform:uppercase;">Contact</div>
</div>
</div>
<div style="flex:1;"></div>
<a class="btn" href="/" style="text-decoration:none;">← App</a>
</div>
</div>
<div style="max-width:640px; margin:0 auto; padding:40px 20px;">
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
Get in touch
</div>
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:36px;
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
line-height:1.05; margin-bottom:8px;">
Contact
</h1>
<p class="subtle" style="margin-bottom:32px; font-size:14px;">
Have a question, found a bug, or want to suggest a feature? We'd love to hear from you.
</p>
{#if submitted}
<div class="panel" style="padding:32px; text-align:center;">
<div style="font-size:28px; margin-bottom:12px; color:var(--green);"></div>
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);
margin-bottom:8px;">Message Sent</div>
<p style="font-size:14px; color:var(--text2); line-height:1.7;">
Thanks for reaching out. We'll get back to you if a reply is needed.
</p>
</div>
{:else}
<form on:submit={handleSubmit} novalidate>
<!-- Honeypot — visually hidden, must stay empty -->
<div style="position:absolute; left:-9999px; opacity:0; pointer-events:none;"
aria-hidden="true">
<label for="__hp">Leave this blank</label>
<input id="__hp" name="__hp" type="text" tabindex="-1" autocomplete="off"
bind:value={honeypot} />
</div>
<!-- Name + Email row -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px;">
<div>
<label class="label" for="contact-name" style="display:block; margin-bottom:6px;">
Name
<span style="font-weight:400; text-transform:none; letter-spacing:0;
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:4px;">
(optional)
</span>
</label>
<input id="contact-name" class="input" type="text"
bind:value={name} maxlength="100"
placeholder="Your name" autocomplete="name" />
</div>
<div>
<label class="label" for="contact-email" style="display:block; margin-bottom:6px;">
Email
<span style="font-weight:400; text-transform:none; letter-spacing:0;
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:4px;">
(optional, for replies)
</span>
</label>
<input id="contact-email" class="input" type="email"
bind:value={email} maxlength="254"
placeholder="you@example.com" autocomplete="email" />
</div>
</div>
<!-- Subject -->
<div style="margin-bottom:14px;">
<label class="label" for="contact-subject" style="display:block; margin-bottom:6px;">
Subject <span style="color:var(--red); margin-left:2px;">*</span>
</label>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
{#each SUBJECT_OPTIONS as opt}
<button
type="button"
style="font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:600;
letter-spacing:0.06em; text-transform:uppercase; padding:5px 12px;
border-radius:3px; cursor:pointer; transition:all 0.15s;
border:1px solid {subject === opt ? 'var(--amber)' : 'var(--border2)'};
background:{subject === opt ? 'var(--amber-dim)' : 'var(--bg)'};
color:{subject === opt ? 'var(--amber)' : 'var(--text2)'};"
on:click={() => subject = opt}
>
{opt}
</button>
{/each}
</div>
</div>
<!-- Message -->
<div style="margin-bottom:20px;">
<label class="label" for="contact-message" style="display:block; margin-bottom:6px;">
Message <span style="color:var(--red); margin-left:2px;">*</span>
</label>
<textarea
id="contact-message"
class="input"
style="resize:vertical; min-height:140px; font-size:14px; line-height:1.6;"
bind:value={message}
maxlength="3000"
placeholder="Tell us what's on your mind…"
></textarea>
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);
text-align:right; margin-top:4px;">
{message.length} / 3000
</div>
</div>
{#if errorMsg}
<div style="margin-bottom:14px; 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;">
{errorMsg}
</div>
{/if}
<button class="btn btn-primary" type="submit"
style="width:100%; justify-content:center; padding:10px;"
disabled={submitting}>
{submitting ? 'Sending…' : 'Send Message'}
</button>
</form>
{/if}
</div>

View File

@@ -0,0 +1,176 @@
<svelte:head>
<title>Disclosures — ETC PRS Editor</title>
</svelte:head>
<!-- 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(--amber);
box-shadow:0 0 10px rgba(232,147,10,0.6); 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(--cyan); opacity:0.8; text-transform:uppercase;">Disclosures</div>
</div>
</div>
<div style="flex:1;"></div>
<a class="btn" href="/about" style="text-decoration:none;">← About</a>
<a class="btn" href="/" style="text-decoration:none;">App</a>
</div>
</div>
<div style="max-width:760px; margin:0 auto; padding:40px 20px;">
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
Transparency
</div>
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:36px;
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
line-height:1.05; margin-bottom:32px;">
Disclosures
</h1>
<!-- Affiliation disclaimer -->
<section style="margin-bottom:36px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
<div style="width:3px; height:20px; background:var(--amber); border-radius:2px;"></div>
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Affiliation & Endorsement
</h2>
</div>
<div class="panel" style="padding:18px 20px;">
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
This project is <strong style="color:var(--text);">not affiliated with, sponsored by,
or endorsed by ETC (Electronic Theatre Controls, Inc.)</strong> in any way.
</p>
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
ETC, Expression, and related product names are trademarks of Electronic Theatre Controls, Inc.
All trademarks are the property of their respective owners.
</p>
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
This tool is an independent, community-built project intended for educational purposes,
interoperability, and supporting legacy lighting systems.
</p>
</div>
</section>
<!-- Reverse engineering -->
<section style="margin-bottom:36px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
<div style="width:3px; height:20px; background:var(--cyan); border-radius:2px;"></div>
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Reverse Engineering
</h2>
</div>
<div class="panel" style="padding:18px 20px;">
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
The <code style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
background:var(--raised); padding:1px 5px; border-radius:2px;">.prs</code> file format
used by ETC Expression lighting consoles is not publicly documented in full detail.
In order to build this tool, the format was analyzed through:
</p>
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
{#each reverseEngineeringPoints as point}
<li style="display:flex; gap:10px; align-items:flex-start;">
<span style="color:var(--cyan); flex-shrink:0; font-family:'DM Mono',monospace;"></span>
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
</li>
{/each}
</ul>
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
No proprietary source code was accessed or used. The reverse engineering was limited
strictly to understanding the file structure for the purpose of interoperability.
</p>
</div>
</section>
<!-- AI disclosure -->
<section style="margin-bottom:36px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
<div style="width:3px; height:20px; background:var(--magenta); border-radius:2px;"></div>
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
AI-Assisted Development
</h2>
</div>
<div class="panel" style="padding:18px 20px;">
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
Significant portions of this application were developed with the assistance of
AI language models, including:
</p>
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
{#each aiPoints as point}
<li style="display:flex; gap:10px; align-items:flex-start;">
<span style="color:var(--magenta); flex-shrink:0; font-family:'DM Mono',monospace;"></span>
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
</li>
{/each}
</ul>
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
All AI-generated output was reviewed, tested, and refined by a human developer
before inclusion. The application logic — particularly the binary parser, 16-bit
channel handling, and PRS export — was carefully verified against real fixture files
and ETC's own tooling.
</p>
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
We believe in being transparent about how software is built. AI assistance does not
diminish the care taken to make this tool accurate and reliable for the lighting community.
</p>
</div>
</section>
<!-- Intent -->
<section>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
<div style="width:3px; height:20px; background:var(--green); border-radius:2px;"></div>
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
Intent & Scope
</h2>
</div>
<div class="panel" style="padding:18px 20px;">
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
This project exists to serve the lighting community. It is intended for:
</p>
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px;">
{#each intentPoints as point}
<li style="display:flex; gap:10px; align-items:flex-start;">
<span style="color:var(--green); flex-shrink:0;"></span>
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
</li>
{/each}
</ul>
</div>
</section>
</div>
<script>
const reverseEngineeringPoints = [
'Inspection and comparison of real .prs files produced by ETC\'s Personality Editor',
'Behavioral analysis — observing how changes in the editor affect the binary output',
'Static analysis of the Personality Editor executable to understand byte-level structure',
];
const aiPoints = [
'UI implementation and component design',
'Data handling logic and state management',
'Iterative refinement of features based on developer feedback',
'The public library, admin panel, and moderation system',
];
const intentPoints = [
'Educational purposes — understanding how fixture personalities are structured',
'Interoperability — making it easier to work with legacy ETC systems',
'Community tooling — sharing fixture personalities with other technicians',
'Supporting legacy systems that may not have modern tooling available',
];
</script>

View File

@@ -0,0 +1,43 @@
import { listPersonalities, getDistinctManufacturers, getManufacturerCounts } from '$lib/server/db.js';
import { MANUFACTURER_SEEDS } from '$lib/server/manufacturers.js';
const VALID_LIMITS = [12, 24, 48, 96];
export async function load({ url }) {
const q = url.searchParams.get('q') ?? '';
const manufacturer = url.searchParams.get('manufacturer') ?? '';
const sort = url.searchParams.get('sort') ?? 'newest';
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
const view = url.searchParams.get('view') ?? 'cards';
const limitParam = parseInt(url.searchParams.get('limit') ?? '24');
const limit = VALID_LIMITS.includes(limitParam) ? limitParam : 24;
const { rows, total } = listPersonalities({ page, limit, sort, manufacturer, q });
const items = rows.map(row => ({
...row,
tags: tryParseJson(row.tags, [])
}));
const dbManufacturers = getDistinctManufacturers();
const manufacturerCounts = getManufacturerCounts();
const manufacturers = [...new Set([...MANUFACTURER_SEEDS, ...dbManufacturers])].sort();
return {
items,
total,
limit,
pages: Math.ceil(total / limit),
page,
q,
manufacturer,
sort,
view,
manufacturers,
manufacturerCounts
};
}
function tryParseJson(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}

View File

@@ -0,0 +1,421 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Search, LayoutGrid, Table2, Download, ArrowUpRight, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { saveStoredFile, getStoredFiles, getSnapshots } from '$lib/storage.js';
import { normalizePersonality, parsePRS } from '$lib/prs.js';
import { makeSlug } from '$lib/shared/slugify.js';
import GlobalMenu from '$lib/components/GlobalMenu.svelte';
export let data;
let searchInput = data.q;
let searchTimer;
// For GlobalMenu — load from localStorage so recent files / snapshots work
import { browser } from '$app/environment';
let previousFiles = [];
let snapshots = [];
if (browser) {
previousFiles = getStoredFiles();
}
async function handleOpenFile(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
try {
const buf = await file.arrayBuffer();
const personality = normalizePersonality(parsePRS(buf));
const stored = saveStoredFile(personality, file.name);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
personality,
mode: 'viewer',
currentFileId: stored.id,
notes: ''
}));
goto('/');
} catch { /* ignore */ } finally {
event.currentTarget.value = '';
}
}
const AUTOSAVE_KEY = 'etc-prs-ui-autosave-v1';
async function loadIntoEditor(item) {
try {
const res = await fetch(`/api/personality/${item.id}/download`);
if (!res.ok) throw new Error('Download failed');
const buf = await res.arrayBuffer();
const personality = normalizePersonality(parsePRS(buf));
const stored = saveStoredFile(personality, item.file_name ?? `${item.name}.prs`, item.notes ?? '');
// Write directly into autosave so main app restores this file, not a stale session
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
personality,
mode: 'viewer',
currentFileId: stored.id,
notes: item.notes ?? ''
}));
goto('/');
} catch (err) {
alert(`Failed to load personality: ${err.message}`);
}
}
function openStoredFile(item) {
const personality = normalizePersonality(item.parsed);
// Write into autosave so main app restores this specific file
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
personality,
mode: 'viewer',
currentFileId: item.id,
notes: item.notes ?? ''
}));
goto('/');
}
function openSnapshot(item) {
// Snapshots don't have a stored file ID — just set mode to editor
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
personality: normalizePersonality(item.parsed),
mode: 'editor',
currentFileId: null,
notes: ''
}));
goto('/');
}
function attrColor(count) {
if (count >= 24) return 'var(--magenta)';
if (count >= 12) return 'var(--cyan)';
if (count >= 6) return 'var(--amber)';
return 'var(--text2)';
}
function navigate(params) {
const u = new URL($page.url);
// Always preserve limit unless explicitly changed
if (!params.limit && data.limit !== 24) {
u.searchParams.set('limit', String(data.limit));
}
for (const [k, v] of Object.entries(params)) {
if (v !== '' && v !== null && v !== undefined) u.searchParams.set(k, String(v));
else u.searchParams.delete(k);
}
goto(u.toString(), { keepFocus: true });
}
function onSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => navigate({ q: searchInput, page: '1' }), 350);
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
</script>
<svelte:head>
<title>Library — ETC PRS Editor</title>
</svelte:head>
<GlobalMenu
currentMode="viewer"
{previousFiles}
{snapshots}
onOpenFile={handleOpenFile}
onNewFile={() => goto('/')}
onOpenStoredFile={openStoredFile}
onOpenSnapshot={openSnapshot}
onGoHome={() => goto('/')}
/>
<!-- Search + filter bar -->
<div style="background:var(--raised); border-bottom:1px solid var(--border); padding:10px 20px;">
<div style="width:100%; max-width:1400px; margin:0 auto; box-sizing:border-box; display:flex; flex-direction:column; gap:8px;">
<!-- Row 1: Search + Manufacturer -->
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px;">
<div style="position:relative; flex:1; min-width:200px;">
<Search size={14} style="position:absolute; left:10px; top:50%; transform:translateY(-50%); color:var(--text3); pointer-events:none;" />
<input
class="input"
style="padding-left:32px;"
type="search"
placeholder="Search fixtures, manufacturers, tags…"
bind:value={searchInput}
on:input={onSearch}
/>
</div>
<select
class="select"
style="min-width:200px; max-width:280px;"
value={data.manufacturer}
on:change={(e) => navigate({ manufacturer: e.currentTarget.value, page: '1' })}
>
<option value="">All Manufacturers ({data.total})</option>
{#each data.manufacturers as m}
{#if data.manufacturerCounts[m]}
<option value={m}>{m} ({data.manufacturerCounts[m]})</option>
{/if}
{/each}
</select>
</div>
<!-- Row 2: Sort + Per-page + spacer + View toggle + Result count -->
<div style="display:flex; align-items:center; gap:8px;">
<!-- Sort -->
<select
class="select"
style="min-width:140px;"
value={data.sort}
on:change={(e) => navigate({ sort: e.currentTarget.value, page: '1' })}
>
<option value="newest">Newest First</option>
<option value="popular">Most Viewed</option>
</select>
<!-- Per-page -->
<select
class="select"
style="min-width:110px;"
value={data.limit}
on:change={(e) => navigate({ limit: e.currentTarget.value, page: '1' })}
>
<option value={12}>12 / page</option>
<option value={24}>24 / page</option>
<option value={48}>48 / page</option>
<option value={96}>96 / page</option>
</select>
<!-- Spacer -->
<div style="flex:1;"></div>
<!-- Result count -->
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
letter-spacing:0.04em; color:var(--text2); white-space:nowrap; flex-shrink:0;">
<span style="font-family:'DM Mono',monospace; font-size:18px; color:var(--amber);
text-shadow:0 0 10px rgba(232,147,10,0.35);">{data.total}</span>
<span style="font-size:13px; color:var(--text3); margin-left:4px;">
result{data.total !== 1 ? 's' : ''}
</span>
</div>
<!-- Divider -->
<div style="width:1px; height:22px; background:var(--border2); flex-shrink:0;"></div>
<!-- View toggle -->
<div style="display:flex; border:1px solid var(--border2); border-radius:3px; overflow:hidden; flex-shrink:0;">
<button class="btn"
style="border:none; border-radius:0; padding:6px 12px;
{data.view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
type="button" on:click={() => navigate({ view: 'cards' })}>
<LayoutGrid size={16} />
</button>
<div style="width:1px; background:var(--border2);"></div>
<button class="btn"
style="border:none; border-radius:0; padding:6px 12px;
{data.view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
type="button" on:click={() => navigate({ view: 'table' })}>
<Table2 size={16} />
</button>
</div>
</div>
</div>
</div>
<!-- Main content -->
<div style="width:100%; max-width:1400px; margin:0 auto; padding:20px; box-sizing:border-box;">
{#if data.items.length === 0}
<div style="text-align:center; padding:80px 20px; color:var(--text3);">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px; color:var(--text2); margin-bottom:8px;">No personalities found</div>
<div style="font-size:13px;">Try a different search or clear the filters.</div>
</div>
{:else if data.view === 'table'}
<!-- Table view -->
<div class="panel" style="overflow:hidden; margin-bottom:20px;">
<div style="overflow-x:auto;">
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:var(--raised);">
{#each ['Fixture', 'Manufacturer', 'Channels', 'Tags', 'By', 'Date', 'Views', ''] as h}
<th style="padding:10px 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.items as item}
<tr style="border-bottom:1px solid var(--border); transition:background 0.1s;"
on:mouseenter={(e) => e.currentTarget.style.background = 'var(--raised)'}
on:mouseleave={(e) => e.currentTarget.style.background = ''}>
<td style="padding:10px 14px;">
<a href="/p/{item.id}/{makeSlug(item.name)}"
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
color:var(--text); text-decoration:none; letter-spacing:0.03em;">
{item.name}
</a>
{#if item.prs_name && item.prs_name !== item.name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);
margin-top:2px;">PRS: {item.prs_name}</div>
{/if}
{#if item.file_name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">{item.file_name}</div>
{/if}
</td>
<td style="padding:10px 14px; font-size:13px; color:var(--text2); white-space:nowrap;">
{item.manufacturer || '—'}
</td>
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:14px; color:{attrColor(item.channel_count)}; white-space:nowrap;">
{item.channel_count}
</td>
<td style="padding:10px 14px; max-width:200px;">
<div style="display:flex; flex-wrap:wrap; gap:4px;">
{#each item.tags as tag}
<span class="badge">{tag}</span>
{/each}
</div>
</td>
<td style="padding:10px 14px; font-size:12px; color:var(--text3); white-space:nowrap;">
{item.creator_handle || '—'}
</td>
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">
{formatDate(item.created_at)}
</td>
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">
{item.view_count}
</td>
<td style="padding:10px 10px; white-space:nowrap;">
<div style="display:flex; gap:5px;">
<button class="btn" style="padding:5px 8px;" type="button"
on:click={() => loadIntoEditor(item)} title="Load into editor">
<ArrowUpRight size={13} />
</button>
<a class="btn" href="/api/personality/{item.id}/download" download
style="padding:5px 8px; text-decoration:none;" title="Download .prs">
<Download size={13} />
</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<!-- Card view -->
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:10px; margin-bottom:20px;">
{#each data.items as item}
<div class="panel" style="display:flex; flex-direction:column; overflow:hidden; min-width:0;
border-top:2px solid {attrColor(item.channel_count)}; transition:border-color 0.15s;">
<!-- Card header -->
<div style="padding:12px 14px 10px; border-bottom:1px solid var(--border); min-width:0;">
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-bottom:4px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{item.manufacturer || 'Unknown'}
</div>
<a href="/p/{item.id}/{makeSlug(item.name)}"
style="display:block; font-family:'Barlow Condensed',sans-serif; font-weight:700;
font-size:20px; letter-spacing:0.03em; color:var(--text); text-decoration:none;
line-height:1.2; margin-bottom:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
title={item.name}>
{item.name}
</a>
{#if item.prs_name && item.prs_name !== item.name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);
margin-bottom:2px;">PRS: {item.prs_name}</div>
{/if}
{#if item.file_name}
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{item.file_name}
</div>
{/if}
</div>
<!-- Card body -->
<div style="padding:10px 14px; flex:1;">
<!-- Channel count LED -->
<div style="display:flex; align-items:baseline; gap:6px; margin-bottom:8px;">
<div style="font-family:'DM Mono',monospace; font-size:26px; font-weight:500;
color:{attrColor(item.channel_count)};
text-shadow:0 0 12px {attrColor(item.channel_count)}40; line-height:1;">
{item.channel_count}
</div>
<div class="label">CH</div>
</div>
<!-- Tags -->
{#if item.tags.length}
<div style="display:flex; flex-wrap:wrap; gap:4px; margin-bottom:8px;">
{#each item.tags as tag}
<span class="badge">{tag}</span>
{/each}
</div>
{/if}
<!-- Meta -->
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); line-height:1.8;">
{#if item.creator_handle}<div>By {item.creator_handle}</div>{/if}
<div>{formatDate(item.created_at)} · {item.view_count} views</div>
</div>
</div>
<!-- Card footer actions -->
<div style="padding:8px 14px; border-top:1px solid var(--border); display:flex; gap:6px;">
<button class="btn" style="flex:1; justify-content:center;" type="button"
on:click={() => loadIntoEditor(item)}>
<ArrowUpRight size={13} /> Load into Editor
</button>
<a class="btn" href="/api/personality/{item.id}/download" download
style="padding:6px 8px; text-decoration:none;" title="Download .prs">
<Download size={13} />
</a>
</div>
</div>
{/each}
</div>
{/if}
<!-- Pagination -->
{#if data.pages > 1}
<div style="display:flex; align-items:center; justify-content:center; gap:4px; padding:8px 0;">
<button class="btn" style="padding:6px 10px;" type="button"
disabled={data.page <= 1}
on:click={() => navigate({ page: String(data.page - 1) })}>
<ChevronLeft size={14} />
</button>
{#each Array.from({ length: data.pages }, (_, i) => i + 1) as p}
{#if data.pages <= 7 || Math.abs(p - data.page) <= 2 || p === 1 || p === data.pages}
<button
class="btn"
style="padding:6px 12px; min-width:36px; justify-content:center;
font-family:'DM Mono',monospace; font-size:12px;
{p === data.page ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
type="button"
on:click={() => navigate({ page: String(p) })}
>{p}</button>
{:else if Math.abs(p - data.page) === 3}
<span style="color:var(--text3); padding:0 4px; font-family:'DM Mono',monospace;"></span>
{/if}
{/each}
<button class="btn" style="padding:6px 10px;" type="button"
disabled={data.page >= data.pages}
on:click={() => navigate({ page: String(data.page + 1) })}>
<ChevronRight size={14} />
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,24 @@
import { error } from '@sveltejs/kit';
import { getPersonalityById } from '$lib/server/db.js';
export async function load({ params }) {
const record = getPersonalityById(params.id);
if (!record) throw error(404, 'Personality not found.');
// Don't increment view count for deleted personalities
if (!record.deleted_at) {
const { incrementViewCount } = await import('$lib/server/db.js');
incrementViewCount(params.id);
}
return {
personality: {
...record,
tags: tryParseJson(record.tags, [])
}
};
}
function tryParseJson(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}

View File

@@ -0,0 +1,339 @@
<script>
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { Download, ArrowUpRight, Trash2, Eye, ChevronDown, Flag } from 'lucide-svelte';
import ChannelCardGrid from '$lib/components/ChannelCardGrid.svelte';
import ChannelTable from '$lib/components/ChannelTable.svelte';
import ReportModal from '$lib/components/ReportModal.svelte';
import { saveStoredFile, getStoredFiles, detachSharedRecord } from '$lib/storage.js';
import { normalizePersonality, parsePRS, buildVisibleEntries } from '$lib/prs.js';
export let data;
const p = data.personality;
let view = 'cards';
let showDeleteForm = false;
let showReportModal = false;
let deleteToken = '';
let deleting = false;
let deleteError = '';
let deleteSuccess = false;
let isOwner = false;
// Check localStorage for a saved token matching this personality's shared_id
if (browser) {
const files = getStoredFiles();
const match = files.find((f) => f.shared_id === p.id);
if (match?.owner_token) {
deleteToken = match.owner_token;
isOwner = true;
}
}
// Parse the personality for display
let displayPersonality = null;
let visibleEntries = [];
async function loadDisplay() {
try {
const res = await fetch(`/api/personality/${p.id}/download`);
if (!res.ok) return;
const buf = await res.arrayBuffer();
displayPersonality = normalizePersonality(parsePRS(buf));
visibleEntries = buildVisibleEntries(displayPersonality.channels);
} catch { /* non-fatal — display still works without channel detail */ }
}
loadDisplay();
async function loadIntoEditor() {
try {
const res = await fetch(`/api/personality/${p.id}/download`);
if (!res.ok) throw new Error('Download failed');
const buf = await res.arrayBuffer();
const personality = normalizePersonality(parsePRS(buf));
saveStoredFile(personality, p.file_name ?? `${p.name}.prs`);
goto('/');
} catch (err) {
alert(`Failed to load: ${err.message}`);
}
}
async function handleDelete() {
if (!deleteToken.trim()) { deleteError = 'Please enter your owner token.'; return; }
deleting = true;
deleteError = '';
try {
const res = await fetch('/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, owner_token: deleteToken.trim() })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `Error ${res.status}`);
}
deleteSuccess = true;
detachSharedRecord(p.id);
setTimeout(() => goto('/library'), 2000);
} catch (err) {
deleteError = err.message;
deleting = false;
}
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString(undefined, { year:'numeric', month:'long', day:'numeric' });
}
function attrColor(count) {
if (count >= 24) return 'var(--magenta)';
if (count >= 12) return 'var(--cyan)';
if (count >= 6) return 'var(--amber)';
return 'var(--text2)';
}
</script>
<svelte:head>
<title>{p.name} — ETC PRS Library</title>
<meta name="description" content="{p.name} by {p.manufacturer ?? 'Unknown'}{p.channel_count} channel ETC PRS personality" />
</svelte:head>
<!-- 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(--amber); box-shadow:0 0 10px rgba(232,147,10,0.6); 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(--cyan); opacity:0.8; text-transform:uppercase;">Personality Library</div>
</div>
</div>
<div style="flex:1;"></div>
<a class="btn" href="/library" style="text-decoration:none;">← Library</a>
<a class="btn" href="/" style="text-decoration:none;">App</a>
</div>
</div>
<div style="max-width:1400px; margin:0 auto; padding:20px;">
<!-- Personality header panel -->
<div class="panel" style="padding:0; overflow:hidden; margin-bottom:16px;">
<!-- Accent strip coloured by channel count -->
<div style="height:3px; background:{attrColor(p.channel_count)};
box-shadow:0 0 12px {attrColor(p.channel_count)}60;"></div>
<div style="padding:20px 24px;">
<!-- Manufacturer + name -->
{#if p.manufacturer}
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);
text-transform:uppercase; letter-spacing:0.1em; margin-bottom:6px;">
{p.manufacturer}
</div>
{/if}
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:32px;
letter-spacing:0.04em; color:var(--text); line-height:1.1; margin-bottom:4px;">
{p.name}
</h1>
{#if p.prs_name && p.prs_name !== p.name}
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
margin-bottom:8px; display:flex; align-items:center; gap:6px;">
<span style="color:var(--text3);">PRS name:</span>
<span>{p.prs_name}</span>
</div>
{/if}
<!-- Stats row -->
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:16px; margin-bottom:12px;">
<div style="display:flex; align-items:baseline; gap:5px;">
<div style="font-family:'DM Mono',monospace; font-size:28px; font-weight:500;
color:{attrColor(p.channel_count)};
text-shadow:0 0 12px {attrColor(p.channel_count)}40; line-height:1;">
{p.channel_count}
</div>
<div class="label">Channels</div>
</div>
<div style="display:flex; align-items:center; gap:5px; color:var(--text3);">
<Eye size={13} />
<span style="font-family:'DM Mono',monospace; font-size:12px;">{p.view_count} views</span>
</div>
{#if p.creator_handle}
<div style="font-size:12px; color:var(--text2);">By <strong>{p.creator_handle}</strong></div>
{/if}
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
{formatDate(p.created_at)}
</div>
{#if p.file_name}
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">{p.file_name}</div>
{/if}
</div>
<!-- Tags -->
{#if p.tags.length}
<div style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:14px;">
{#each p.tags as tag}
<span class="badge">{tag}</span>
{/each}
</div>
{/if}
<!-- Notes -->
{#if p.notes}
<div style="padding:10px 12px; border-radius:3px; border:1px solid var(--border);
background:var(--raised); font-size:13px; color:var(--text2);
line-height:1.6; margin-bottom:14px;">
{p.notes}
</div>
{/if}
<!-- Actions -->
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px;">
{#if !p.deleted_at}
<button class="btn btn-primary" type="button" on:click={loadIntoEditor}>
<ArrowUpRight size={14} /> Load into Editor
</button>
<a class="btn" href="/api/personality/{p.id}/download" download="{p.name}.prs"
style="text-decoration:none;">
<Download size={14} /> Download .prs
</a>
{/if}
<!-- Report button — subtle, right-aligned -->
<button
class="btn"
style="margin-left:auto; color:var(--text3);"
type="button"
on:click={() => showReportModal = true}
>
<Flag size={13} /> Report
</button>
<!-- Delete toggle — shown to everyone, but pre-filled for owner -->
{#if !p.deleted_at}
<button
class="btn"
style="{isOwner ? 'color:var(--red); border-color:rgba(248,113,113,0.3);' : 'color:var(--text3);'}"
type="button"
on:click={() => { showDeleteForm = !showDeleteForm; deleteError = ''; }}
>
<Trash2 size={13} />
{isOwner ? 'Delete (you own this)' : 'I own this'}
<ChevronDown size={12} style="transition:transform 0.2s; {showDeleteForm ? 'transform:rotate(180deg)' : ''}" />
</button>
{/if}
</div>
<!-- Soft-deleted notice -->
{#if p.deleted_at}
<div style="margin-top:16px; padding:14px; border-radius:3px;
border:1px solid rgba(248,113,113,0.25); background:var(--red-dim);
color:var(--red); font-size:13px; line-height:1.6;">
<strong style="font-family:'Barlow Condensed',sans-serif; font-size:14px;
letter-spacing:0.06em; text-transform:uppercase;">
This personality has been removed.
</strong><br/>
It is no longer available in the library. If you believe this was in error,
please <a href="/" style="color:var(--red);">contact us</a>.
</div>
{/if}
<!-- Delete form — collapsible -->
{#if showDeleteForm}
<div style="margin-top:14px; padding:14px; border-radius:3px;
border:1px solid rgba(248,113,113,0.25); background:var(--red-dim);">
{#if deleteSuccess}
<div style="font-family:'Barlow Condensed',sans-serif; font-size:14px; font-weight:600;
color:var(--green); letter-spacing:0.04em;">
Deleted. Redirecting to library…
</div>
{:else}
<div class="label" style="color:var(--red); margin-bottom:8px;">Delete this personality</div>
<p style="font-size:12px; color:var(--text2); margin-bottom:10px; line-height:1.6;">
This action is permanent and cannot be undone.
{#if isOwner}
Your owner token has been detected from your browser's local storage and pre-filled below.
{:else}
Enter your owner token to confirm.
{/if}
</p>
<div style="display:flex; gap:8px;">
{#if isOwner}
<input
class="input"
style="flex:1; font-family:'DM Mono',monospace; font-size:12px;"
type="text"
bind:value={deleteToken}
readonly
/>
{:else}
<input
class="input"
style="flex:1; font-family:'DM Mono',monospace; font-size:12px;"
type="password"
placeholder="Paste your owner token here…"
bind:value={deleteToken}
on:keydown={(e) => e.key === 'Enter' && handleDelete()}
/>
{/if}
<button class="btn btn-danger" type="button" on:click={handleDelete} disabled={deleting}>
<Trash2 size={13} />
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
{#if deleteError}
<div style="margin-top:8px; font-size:12px; color:var(--red);">{deleteError}</div>
{/if}
{/if}
</div>
{/if}
</div>
</div>
<!-- Channel view — only show if not deleted -->
{#if !p.deleted_at}
{#if visibleEntries.length > 0}
<!-- Mini secondary bar -->
<div style="display:flex; align-items:center; justify-content:space-between;
margin-bottom:12px; padding:6px 0;">
<div class="label">{visibleEntries.length} entries · {p.channel_count} channels</div>
<div style="display:flex; border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
<button class="btn" type="button"
style="border:none; border-radius:0; padding:5px 10px;
{view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
on:click={() => view = 'cards'}>
Cards
</button>
<div style="width:1px; background:var(--border2);"></div>
<button class="btn" type="button"
style="border:none; border-radius:0; padding:5px 10px;
{view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
on:click={() => view = 'table'}>
Table
</button>
</div>
</div>
{#if view === 'cards'}
<ChannelCardGrid entries={visibleEntries} editable={false} attributes={[]} />
{:else}
<ChannelTable entries={visibleEntries} editable={false} attributes={[]} />
{/if}
{:else}
<div style="text-align:center; padding:40px; color:var(--text3);">
<div style="font-size:13px;">Loading channel data…</div>
</div>
{/if}
{/if}
</div>
{#if showReportModal}
<ReportModal
personalityId={p.id}
personalityName={p.name}
onDone={() => showReportModal = false}
onCancel={() => showReportModal = false}
/>
{/if}