Supra_App/src/routes/(authed)/tools/video-mainpage/+page.svelte
thanawat saiyota 47ee23777d feat: main & brewing video tool, catalog APIs, sheet/recipe updates
- Add /tools/video-mainpage page (main + brewing-page advertisement videos,
  date-gated, per-country, push to machine over ADB) + api/video-mainpage
  create/list/update proxies; sidebar entry "Main & Brewing Video"
- Add catalog API proxies (catalog-create, catalog-list, catalog-banner,
  catalog-banner-image)
- Sheet: overview/edit/add/priceslot/price updates, stores & services
- Misc: adb, websocket/message handlers, crypto, recipe & brew tweaks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:38:59 +07:00

797 lines
32 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { auth } from '$lib/core/stores/auth';
import { addNotification } from '$lib/core/stores/noti';
import Button from '$lib/components/ui/button/button.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import Progress from '$lib/components/ui/progress/progress.svelte';
import {
Upload,
X,
Film,
MonitorPlay,
CoffeeIcon,
Pencil,
Lock,
RefreshCw,
CalendarDays,
Clock,
ChevronDown,
ImageIcon
} from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
import { env } from '$env/dynamic/public';
import { AdbInstance } from '../../../state.svelte';
const CREATE_ENDPOINT = '/api/video-mainpage';
const LIST_ENDPOINT = '/api/video-mainpage/list';
const UPDATE_ENDPOINT = '/api/video-mainpage/update';
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
const GET_IMAGE = env.PUBLIC_GET_IMAGE;
const DURATION_TRIM = 4; // brewing play seconds = video length 4
// taobin_project-relative path -> served URL (works for video/ and inter/<c>/video/).
const videoUrl = (path: string) => `${GET_IMAGE}/${path}`;
// Only Thailand is enabled for now. To re-enable a country later, uncomment it
// here (the backend already supports inter/<country>/video for all of these).
const COUNTRIES = [
{ value: 'tha', label: 'Thailand (tha)' }
// { value: 'aus', label: 'Australia (aus)' },
// { value: 'gbr', label: 'United Kingdom (gbr)' },
// { value: 'gbr_premium', label: 'UK Premium (gbr_premium)' },
// { value: 'hkg', label: 'Hong Kong (hkg)' },
// { value: 'ltu', label: 'Lithuania (ltu)' },
// { value: 'mys', label: 'Malaysia (mys)' },
// { value: 'rou', label: 'Romania (rou)' },
// { value: 'sgp', label: 'Singapore (sgp)' },
// { value: 'tha_premium', label: 'Thailand Premium (tha_premium)' },
// { value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' },
// { value: 'usa', label: 'USA (usa)' }
];
let country = $state('tha');
const countryLabel = $derived(COUNTRIES.find((c) => c.value === country)?.label ?? country);
interface MediaInfo {
filename: string;
video: string;
size: number | null;
duration?: number | null;
}
interface ManagedVideo {
n: number;
slug: string;
name: string;
start: string;
end: string;
range_label: string;
main: MediaInfo | null;
brewing: MediaInfo | null;
editable: true;
}
interface ReadonlyVideo {
filename: string;
video: string;
size: number | null;
source: string;
}
// ── create form ─────────────────────────────────────────────────────────
let name = $state('');
let startDate = $state('');
let endDate = $state('');
let mainFile = $state<File | null>(null);
let mainPreview = $state('');
let brewingFile = $state<File | null>(null);
let brewingPreview = $state('');
let brewingRawSeconds = $state(0);
let brewingTxtFile = $state<File | null>(null);
let brewingTxtPreview = $state('');
let brewingTxtEnFile = $state<File | null>(null);
let brewingTxtEnPreview = $state('');
const brewingPlaySeconds = $derived(Math.max(1, Math.round(brewingRawSeconds) - DURATION_TRIM));
let submitting = $state(false);
let connecting = $state(false);
let pushProgress = $state({ percent: 0, name: '', active: false });
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
// ── list ────────────────────────────────────────────────────────────────
let managed = $state<ManagedVideo[]>([]);
let readonlyList = $state<ReadonlyVideo[]>([]);
let loadingList = $state(false);
let showReadonly = $state(false);
// ── edit dialog ───────────────────────────────────────────────────────────
let editOpen = $state(false);
let editTarget = $state<ManagedVideo | null>(null);
let editName = $state('');
let editStart = $state('');
let editEnd = $state('');
let editMainFile = $state<File | null>(null);
let editBrewingFile = $state<File | null>(null);
let editBrewingRaw = $state(0);
let editBrewingTxtFile = $state<File | null>(null);
let editBrewingTxtEnFile = $state<File | null>(null);
let editSaving = $state(false);
const editBrewingPlaySeconds = $derived(Math.max(1, Math.round(editBrewingRaw) - DURATION_TRIM));
function toIso(d: string): string {
return d ? `${d}T00:00:00` : '';
}
function fmtMB(bytes: number | null | undefined): string {
return bytes ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : '—';
}
function readVideoDuration(file: File): Promise<number> {
return new Promise((resolve) => {
const v = document.createElement('video');
v.preload = 'metadata';
const url = URL.createObjectURL(file);
v.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve(Number.isFinite(v.duration) ? v.duration : 0);
};
v.onerror = () => {
URL.revokeObjectURL(url);
resolve(0);
};
v.src = url;
});
}
async function machineVideoNumbers(): Promise<string> {
try {
const res = await adb.executeCmd(`ls ${MACHINE_PROJECT_DIR}/video`);
const out = typeof res === 'object' && res ? ((res as { output?: string }).output ?? '') : '';
const nums = [...out.matchAll(/brewing_adv(\d+)/g)].map((m) => m[1]);
return [...new Set(nums)].join(',');
} catch {
return '';
}
}
async function connectMachine() {
if (AdbInstance.instance) {
addNotification('INFO:Machine already connected');
return;
}
connecting = true;
try {
await adb.connnectViaWebUSB(false);
addNotification(AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected');
} catch (error) {
addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
connecting = false;
}
}
function pickMain(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
if (mainPreview) URL.revokeObjectURL(mainPreview);
mainFile = file;
mainPreview = URL.createObjectURL(file);
}
async function pickBrewing(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
brewingFile = file;
brewingPreview = URL.createObjectURL(file);
brewingRawSeconds = await readVideoDuration(file);
}
function pickPng(event: Event, set: (f: File, url: string) => void) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
set(file, URL.createObjectURL(file));
}
function pickBrewingTxt(e: Event) {
pickPng(e, (f, url) => {
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
brewingTxtFile = f;
brewingTxtPreview = url;
});
}
function pickBrewingTxtEn(e: Event) {
pickPng(e, (f, url) => {
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
brewingTxtEnFile = f;
brewingTxtEnPreview = url;
});
}
function clearMain() {
if (mainPreview) URL.revokeObjectURL(mainPreview);
mainFile = null;
mainPreview = '';
}
function clearBrewing() {
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
brewingFile = null;
brewingPreview = '';
brewingRawSeconds = 0;
}
function clearBrewingTxt() {
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
brewingTxtFile = null;
brewingTxtPreview = '';
}
function clearBrewingTxtEn() {
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
brewingTxtEnFile = null;
brewingTxtEnPreview = '';
}
async function pushBinaries(items: { rel: string; file: File }[]) {
const dirs = [...new Set(items.map((it) => it.rel.slice(0, it.rel.lastIndexOf('/'))))];
for (const d of dirs) await adb.executeCmd(`mkdir -p "${MACHINE_PROJECT_DIR}/${d}"`);
for (const it of items) {
const label = it.rel.split('/').pop() ?? it.rel;
pushProgress = { percent: 0, name: label, active: true };
const bytes = new Uint8Array(await it.file.arrayBuffer());
const ok = await adb.pushBinary(`${MACHINE_PROJECT_DIR}/${it.rel}`, bytes, (sent, total) => {
pushProgress = {
percent: total > 0 ? Math.round((sent / total) * 100) : 0,
name: label,
active: true
};
});
if (!ok) throw new Error(`push ${label} failed`);
}
}
// Push each uploaded file to every target path the backend returned (one per base).
function targetItems(
targets: Record<string, string[]>,
files: Record<string, File | null>
): { rel: string; file: File }[] {
const items: { rel: string; file: File }[] = [];
for (const kind of ['main', 'brewing', 'txt', 'txt_en']) {
const f = files[kind];
if (!f) continue;
for (const rel of targets?.[kind] ?? []) items.push({ rel, file: f });
}
return items;
}
async function pushScripts(scripts: { path: string; content: string }[]) {
for (const s of scripts) {
if (!s?.path || typeof s?.content !== 'string') continue;
pushProgress = { percent: 100, name: s.path.split('/').pop() ?? s.path, active: true };
await adb.push(`${MACHINE_PROJECT_DIR}/${s.path}`, s.content);
}
}
async function handleSubmit() {
const user = $auth;
if (!user) return addNotification('ERR:Not logged in');
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
if (!name.trim() || !startDate || !mainFile || !brewingFile || !brewingTxtFile || !brewingTxtEnFile)
return addNotification(
'ERR:Need a name, start date, both videos, and both brewing text overlays (TH + EN)'
);
submitting = true;
try {
const fd = new FormData();
fd.append('uid', user.uid);
fd.append('displayName', user.displayName || 'unknown');
fd.append('email', user.email || 'unknown@email.com');
fd.append('name', name.trim());
fd.append('country', country);
fd.append('start', toIso(startDate));
fd.append('end', endDate ? toIso(endDate) : 'NONE');
fd.append('machine_numbers', await machineVideoNumbers());
fd.append('brewing_duration', String(brewingPlaySeconds));
fd.append('video', mainFile);
fd.append('brewing_video', brewingFile);
fd.append('brewing_txt', brewingTxtFile);
fd.append('brewing_txt_en', brewingTxtEnFile);
const res = await fetch(CREATE_ENDPOINT, { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(e.detail || e.message || 'Add video failed');
}
const result = await res.json();
await pushBinaries(
targetItems(result.targets, {
main: mainFile,
brewing: brewingFile,
txt: brewingTxtFile,
txt_en: brewingTxtEnFile
})
);
await pushScripts(result?.content?.scripts ?? []);
if (result?.sftp?.error)
addNotification(`WARN:Uploaded but FTP sync failed: ${result.sftp.error}`);
addNotification(`INFO:Added "${name.trim()}" as brewing_adv${result.n} (${country})`);
clearMain();
clearBrewing();
clearBrewingTxt();
clearBrewingTxtEn();
name = '';
startDate = '';
endDate = '';
await loadList();
} catch (error) {
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
} finally {
submitting = false;
pushProgress = { percent: 0, name: '', active: false };
}
}
async function loadList() {
loadingList = true;
try {
const res = await fetch(`${LIST_ENDPOINT}?country=${encodeURIComponent(country)}`, {
method: 'POST'
});
if (!res.ok) throw new Error('list failed');
const data = await res.json();
managed = data.managed ?? [];
readonlyList = data.readonly ?? [];
} catch (error) {
addNotification(`ERR:Load list failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
loadingList = false;
}
}
function openEdit(v: ManagedVideo) {
editTarget = v;
editName = v.name;
editStart = v.start ? v.start.slice(0, 10) : '';
editEnd = v.end && v.end !== 'NONE' ? v.end.slice(0, 10) : '';
editMainFile = null;
editBrewingFile = null;
editBrewingRaw = 0;
editBrewingTxtFile = null;
editBrewingTxtEnFile = null;
editOpen = true;
}
function pickEditMain(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file && file.name.toLowerCase().endsWith('.mp4')) editMainFile = file;
else if (file) addNotification('WARN:Only .mp4 allowed');
}
async function pickEditBrewing(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
editBrewingFile = file;
editBrewingRaw = await readVideoDuration(file);
}
function pickEditTxt(event: Event, en: boolean) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
if (en) editBrewingTxtEnFile = file;
else editBrewingTxtFile = file;
}
async function submitEdit() {
const user = $auth;
if (!user || !editTarget) return;
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
if (!editStart) return addNotification('ERR:Start date required');
const target = editTarget;
editSaving = true;
try {
const fd = new FormData();
fd.append('slug', target.slug);
fd.append('uid', user.uid);
fd.append('displayName', user.displayName || 'unknown');
fd.append('email', user.email || 'unknown@email.com');
fd.append('country', country);
fd.append('name', editName.trim() || target.name);
fd.append('start', toIso(editStart));
fd.append('end', editEnd ? toIso(editEnd) : 'NONE');
if (editBrewingFile) fd.append('brewing_duration', String(editBrewingPlaySeconds));
else if (target.brewing?.duration) fd.append('brewing_duration', String(target.brewing.duration));
if (editMainFile) fd.append('video', editMainFile);
if (editBrewingFile) fd.append('brewing_video', editBrewingFile);
if (editBrewingTxtFile) fd.append('brewing_txt', editBrewingTxtFile);
if (editBrewingTxtEnFile) fd.append('brewing_txt_en', editBrewingTxtEnFile);
const res = await fetch(UPDATE_ENDPOINT, { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(e.detail || 'Update failed');
}
const result = await res.json();
await pushBinaries(
targetItems(result.targets ?? {}, {
main: editMainFile,
brewing: editBrewingFile,
txt: editBrewingTxtFile,
txt_en: editBrewingTxtEnFile
})
);
await pushScripts(result?.content?.scripts ?? []);
if (result?.sftp?.error)
addNotification(`WARN:Updated but FTP sync failed: ${result.sftp.error}`);
addNotification(`INFO:Updated "${target.name}" (${country})`);
editOpen = false;
await loadList();
} catch (error) {
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
} finally {
editSaving = false;
pushProgress = { percent: 0, name: '', active: false };
}
}
// Load (and reload) the list whenever the selected country changes; runs on mount.
$effect(() => {
void country;
loadList();
});
$effect(() => {
return () => {
if (mainPreview) URL.revokeObjectURL(mainPreview);
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
};
});
</script>
<div class="flex min-h-screen flex-col">
<!-- Header -->
<div class="sticky top-0 z-10 border-b bg-background">
<div class="flex items-center justify-between px-8 py-4">
<div>
<h1 class="text-2xl font-bold">Advertisement Videos</h1>
<p class="text-sm text-muted-foreground">Main-page &amp; brewing-page videos, scheduled by date</p>
</div>
<div class="flex items-center gap-3">
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
</Badge>
{#if !isAdbConnected}
<Button variant="outline" onclick={connectMachine} disabled={connecting}>
{#if connecting}<Spinner class="mr-2 h-4 w-4" />Connecting...{:else}<MonitorPlay class="mr-2 h-4 w-4" />Connect Machine{/if}
</Button>
{/if}
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-8">
<div class="mx-auto max-w-5xl space-y-8">
<!-- Create -->
<Card.Root class="overflow-hidden shadow-sm">
<Card.Header>
<Card.Title class="flex items-center gap-2 text-lg">
<Upload class="h-5 w-5 text-muted-foreground" /> Add a new video
</Card.Title>
<Card.Description>
Upload the same clip twice — the main-page version and the brewing-page
<code class="font-mono">_long</code> version. Auto-named
<code class="font-mono">brewing_adv&lt;N&gt;</code> (next free 140, never overwrites a video in use).
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label>Country</Label>
<Select.Root type="single" bind:value={country}>
<Select.Trigger class="w-full">{countryLabel}</Select.Trigger>
<Select.Content>
{#each COUNTRIES as c (c.value)}
<Select.Item value={c.value}>{c.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Writes to <code class="font-mono">inter/{country}/video</code>{country === 'tha' ? ' + flat video/' : ''}.
</p>
</div>
<div class="space-y-2">
<Label for="v-name">Name</Label>
<Input id="v-name" bind:value={name} placeholder="e.g. Bas Bew Bow Brewing" />
<p class="text-xs text-muted-foreground">Used for the comment &amp; the <code class="font-mono">…VideoEnable</code> variable.</p>
</div>
<div class="space-y-2">
<Label for="v-start" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> Start date</Label>
<Input id="v-start" type="date" bind:value={startDate} />
</div>
<div class="space-y-2">
<Label for="v-end" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> End date <span class="text-muted-foreground">(optional)</span></Label>
<Input id="v-end" type="date" bind:value={endDate} />
{#if !endDate}<p class="text-xs text-muted-foreground">Blank = open-ended</p>{/if}
</div>
</div>
<!-- two upload tiles -->
<div class="grid gap-4 md:grid-cols-2">
<!-- main -->
<div class="rounded-xl border p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
<Film class="h-4 w-4 text-muted-foreground" /> Main-page video
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv&lt;N&gt;.mp4</Badge>
</div>
{#if mainFile}
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
<!-- svelte-ignore a11y_media_has_caption -->
<video src={mainPreview} class="h-full w-full object-contain" muted controls></video>
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearMain}><X class="h-3.5 w-3.5" /></button>
</div>
<p class="mt-2 truncate text-xs text-muted-foreground" title={mainFile.name}>{mainFile.name} · {fmtMB(mainFile.size)}</p>
{:else}
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickMain} />
<Film class="mb-2 h-8 w-8" />
<span class="text-sm font-medium">Click to select .mp4</span>
</label>
{/if}
</div>
<!-- brewing -->
<div class="rounded-xl border p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
<CoffeeIcon class="h-4 w-4 text-muted-foreground" /> Brewing-page video
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv&lt;N&gt;_long.mp4</Badge>
</div>
{#if brewingFile}
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
<!-- svelte-ignore a11y_media_has_caption -->
<video src={brewingPreview} class="h-full w-full object-contain" muted controls></video>
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearBrewing}><X class="h-3.5 w-3.5" /></button>
</div>
<p class="mt-2 truncate text-xs text-muted-foreground" title={brewingFile.name}>{brewingFile.name} · {fmtMB(brewingFile.size)}</p>
<p class="mt-1 flex items-center gap-1.5 text-xs font-medium">
<Clock class="h-3.5 w-3.5 text-muted-foreground" />
Plays {brewingPlaySeconds}s
<span class="text-muted-foreground">(length {Math.round(brewingRawSeconds)}s {DURATION_TRIM})</span>
</p>
{:else}
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickBrewing} />
<CoffeeIcon class="mb-2 h-8 w-8" />
<span class="text-sm font-medium">Click to select _long .mp4</span>
</label>
{/if}
<!-- text overlays (required) -->
<div class="mt-3 border-t pt-3">
<p class="mb-2 flex items-center gap-1.5 text-xs font-semibold">
<ImageIcon class="h-3.5 w-3.5 text-muted-foreground" /> Text overlays (.png, required)
</p>
<div class="grid grid-cols-2 gap-2">
{#each [{ label: 'Thai', name: 'brewing_txt_adv<N>.png', file: brewingTxtFile, preview: brewingTxtPreview, pick: pickBrewingTxt, clear: clearBrewingTxt }, { label: 'English', name: 'brewing_txt_adv<N>_en.png', file: brewingTxtEnFile, preview: brewingTxtEnPreview, pick: pickBrewingTxtEn, clear: clearBrewingTxtEn }] as t (t.label)}
<div>
<p class="mb-1 text-[11px] font-medium text-muted-foreground">{t.label}</p>
{#if t.file}
<div class="relative overflow-hidden rounded-md border bg-muted/30">
<img src={t.preview} alt={t.label} class="h-20 w-full object-contain" />
<button class="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white" onclick={t.clear}><X class="h-3 w-3" /></button>
</div>
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={t.file.name}>{t.file.name}</p>
{:else}
<label class="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-[11px] text-muted-foreground transition hover:bg-muted/50">
<input type="file" accept=".png,image/png" class="hidden" onchange={t.pick} />
<ImageIcon class="h-4 w-4" /> {t.label} .png
</label>
{/if}
</div>
{/each}
</div>
</div>
</div>
</div>
{#if pushProgress.active && submitting}
<div class="space-y-1">
<Progress value={pushProgress.percent} max={100} class="h-2" />
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="justify-end gap-2 border-t bg-muted/30 py-4">
<Button size="lg" onclick={handleSubmit} disabled={submitting || !isAdbConnected}>
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Create &amp; Push{/if}
</Button>
</Card.Footer>
</Card.Root>
<!-- Existing -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Existing videos</h2>
<Button variant="ghost" size="sm" onclick={loadList} disabled={loadingList}>
{#if loadingList}<Spinner class="mr-2 h-3.5 w-3.5" />{:else}<RefreshCw class="mr-2 h-3.5 w-3.5" />{/if}Refresh
</Button>
</div>
<!-- managed -->
{#if managed.length === 0}
<p class="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
No web-managed videos yet. Ones you add above appear here and can be edited.
</p>
{:else}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each managed as v (v.slug)}
<Card.Root class="overflow-hidden">
<div class="grid grid-cols-2 gap-px bg-border">
<div class="bg-black">
{#if v.main}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl(v.main.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
{:else}
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no main</div>
{/if}
</div>
<div class="bg-black">
{#if v.brewing}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl(v.brewing.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
{:else}
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no brewing</div>
{/if}
</div>
</div>
<div class="flex items-center justify-between gap-2 p-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold" title={v.name}>{v.name}</p>
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<Badge variant="secondary" class="font-mono">brewing_adv{v.n}</Badge>
<span class="flex items-center gap-1"><CalendarDays class="h-3 w-3" />{v.range_label}</span>
{#if v.brewing?.duration}<span class="flex items-center gap-1"><Clock class="h-3 w-3" />{v.brewing.duration}s</span>{/if}
<Badge variant={v.main ? 'default' : 'outline'} class="text-[10px]">main</Badge>
<Badge variant={v.brewing ? 'default' : 'outline'} class="text-[10px]">brewing</Badge>
</div>
</div>
<Button variant="outline" size="sm" onclick={() => openEdit(v)}>
<Pencil class="mr-1.5 h-3.5 w-3.5" />Edit
</Button>
</div>
</Card.Root>
{/each}
</div>
{/if}
<!-- read-only -->
<div class="rounded-lg border bg-card">
<button
type="button"
class="flex w-full items-center gap-2 p-3 text-sm font-semibold text-muted-foreground transition hover:bg-muted/40"
onclick={() => (showReadonly = !showReadonly)}
>
<ChevronDown class="h-4 w-4 shrink-0 transition-transform {showReadonly ? 'rotate-180' : ''}" />
<Lock class="h-3.5 w-3.5" />
Hand-maintained videos ({readonlyList.length})
<Badge variant="secondary" class="ml-1 text-[10px]">read-only</Badge>
<span class="ml-auto text-xs font-normal">{showReadonly ? 'Click to hide' : 'Click to show'}</span>
</button>
{#if showReadonly}
<div class="grid grid-cols-2 gap-3 p-3 pt-0 sm:grid-cols-3 lg:grid-cols-5">
{#each readonlyList as v (v.filename)}
<div class="overflow-hidden rounded-md border bg-muted/30">
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl(v.video)} class="aspect-video w-full bg-black object-contain" preload="metadata" muted controls></video>
<p class="truncate px-1.5 py-1 font-mono text-[10px]" title={v.filename}>{v.filename}</p>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Edit dialog -->
<Dialog.Root bind:open={editOpen}>
<Dialog.Content class="sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit video</Dialog.Title>
<Dialog.Description>
{#if editTarget}<code class="font-mono">brewing_adv{editTarget.n}</code> · change dates, rename, or replace either clip{/if}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-2">
<div class="space-y-2">
<Label for="e-name">Name</Label>
<Input id="e-name" bind:value={editName} />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="e-start">Start date</Label>
<Input id="e-start" type="date" bind:value={editStart} />
</div>
<div class="space-y-2">
<Label for="e-end">End date <span class="text-muted-foreground">(optional)</span></Label>
<Input id="e-end" type="date" bind:value={editEnd} />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border p-2">
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><Film class="h-3.5 w-3.5 text-muted-foreground" /> Main-page</p>
{#if editTarget?.main}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl(editTarget.main.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
{/if}
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditMain} />
<Upload class="h-3.5 w-3.5" /> {editMainFile ? editMainFile.name : 'Replace (optional)'}
</label>
</div>
<div class="rounded-lg border p-2">
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><CoffeeIcon class="h-3.5 w-3.5 text-muted-foreground" /> Brewing-page</p>
{#if editTarget?.brewing}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl(editTarget.brewing.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
{/if}
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditBrewing} />
<Upload class="h-3.5 w-3.5" /> {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
</label>
{#if editBrewingFile}
<p class="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground"><Clock class="h-3 w-3" />Plays {editBrewingPlaySeconds}s</p>
{/if}
<div class="mt-2 grid grid-cols-2 gap-1.5">
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, false)} />
<ImageIcon class="h-3 w-3" /> {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
</label>
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, true)} />
<ImageIcon class="h-3 w-3" /> {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
</label>
</div>
</div>
</div>
{#if pushProgress.active}
<div class="space-y-1">
<Progress value={pushProgress.percent} max={100} class="h-2" />
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (editOpen = false)} disabled={editSaving}>Cancel</Button>
<Button onclick={submitEdit} disabled={editSaving || !isAdbConnected}>
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Save &amp; Push{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>