645 lines
30 KiB
Svelte
645 lines
30 KiB
Svelte
<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="/cdn-cgi/l/email-protection#205b4d53470e454d41494c5d" 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>
|