Supra_App/src/routes/(authed)/tools/adv-upload/+page.svelte

833 lines
28 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 * as Card from '$lib/components/ui/card/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, Video, CheckCircle, AlertCircle, MonitorPlay } from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
import { AdbInstance } from '../../../state.svelte';
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
const MANIFEST_PROXY_ENDPOINT = '/api/adv-manifest';
const MANIFEST_FILENAME = 'sync_1.file';
const ALLOWED_EXTENSIONS = ['.mp4'];
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
// ─────────────────────────────────────────────────────────────────────────
// CONFIG — choose how the sync_1.file manifest is built (change this value).
// Either way the manifest lists ONLY the selected/active set, never the whole
// FTP folder (production keeps many inactive variants in the folder).
// 'ftp_listdir' = manifest built (in the browser) from the files you upload
// this session. No ADB, doesn't touch the machine. Recommended.
// 'machine' = original flow. On Upload it: rm -rf the machine adv folder,
// pushes the selected .mp4 (from the browser), then
// `ls -l > sync_1.file` on the machine, pulls it, uploads it.
// ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set.
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
// ─────────────────────────────────────────────────────────────────────────
// adv folder on the machine. Domestic Thailand uses the flat folder; every
// international country uses inter/<country>/adv (matches the on-machine and
// taobin_project source structure).
function machineAdvDir(country: string): string {
return country === 'tha'
? `${MACHINE_PROJECT_DIR}/adv`
: `${MACHINE_PROJECT_DIR}/inter/${country}/adv`;
}
// Each country can target a different SFTP host (configured on the backend
// via ADV_SFTP_COUNTRY_CONFIG). The selected country is sent with the upload.
const COUNTRIES = [
{ value: 'tha', label: 'Thailand (tha)' },
{ value: 'mys', label: 'Malaysia (mys)' },
{ value: 'aus', label: 'Australia (aus)' },
{ value: 'sgp', label: 'Singapore (sgp)' },
{ value: 'hkg', label: 'Hong Kong (hkg)' },
{ value: 'gbr', label: 'United Kingdom (gbr)' },
{ value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' },
{ value: 'ltu', label: 'Lithuania (ltu)' },
{ value: 'rou', label: 'Romania (rou)' },
{ value: 'lva', label: 'Latvia (lva)' },
{ value: 'est', label: 'Estonia (est)' }
];
let selectedCountry = $state('tha');
// Expected dimensions are derived from the filename convention.
const ADV_SPECS = {
menu: { width: 1080, height: 380, label: 'Menu banner', pattern: /^taobin_adv_menu_[a-z0-9]+\.mp4$/i },
fullscreen: { width: 1080, height: 608, label: 'Fullscreen / Idle', pattern: /^taobin_adv_[a-z0-9]+\.mp4$/i }
} as const;
type AdvCategory = 'menu' | 'fullscreen' | 'invalid';
interface AdvFileItem {
id: string;
file: File;
preview: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
category: AdvCategory;
expectedWidth?: number;
expectedHeight?: number;
width?: number;
height?: number;
dimChecked: boolean;
dimOk: boolean;
}
let files = $state<AdvFileItem[]>([]);
let uploading = $state(false);
let uploadProgress = $state({ current: 0, total: 0 });
let dragOver = $state(false);
// Push-to-machine (ADB) state
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let pushingToMachine = $state(false);
let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 });
let generatingManifest = $state(false);
function generateId() {
return Math.random().toString(36).substring(2, 9);
}
// Classify by filename. Menu must be checked before fullscreen because a menu
// name also satisfies the broader fullscreen pattern.
function classify(name: string): { category: AdvCategory; width?: number; height?: number } {
const lower = name.toLowerCase();
if (!lower.endsWith('.mp4')) return { category: 'invalid' };
if (ADV_SPECS.menu.pattern.test(lower)) {
return { category: 'menu', width: ADV_SPECS.menu.width, height: ADV_SPECS.menu.height };
}
if (ADV_SPECS.fullscreen.pattern.test(lower)) {
return {
category: 'fullscreen',
width: ADV_SPECS.fullscreen.width,
height: ADV_SPECS.fullscreen.height
};
}
return { category: 'invalid' };
}
function readVideoDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
const url = URL.createObjectURL(file);
video.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve({ width: video.videoWidth, height: video.videoHeight });
};
video.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Cannot read video metadata'));
};
video.src = url;
});
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
addFiles(Array.from(input.files));
}
input.value = '';
}
function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (event.dataTransfer?.files) {
addFiles(Array.from(event.dataTransfer.files));
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
dragOver = false;
}
function addFiles(newFiles: File[]) {
const toAdd: AdvFileItem[] = [];
for (const file of newFiles) {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
addNotification(`WARN:${file.name} - Only .mp4 allowed`);
continue;
}
if (files.some((f) => f.file.name === file.name)) {
addNotification(`WARN:${file.name} - Already added`);
continue;
}
const { category, width, height } = classify(file.name);
if (category === 'invalid') {
addNotification(
`WARN:${file.name} - Name must be taobin_adv_*.mp4 or taobin_adv_menu_*.mp4`
);
continue;
}
toAdd.push({
id: generateId(),
file,
preview: URL.createObjectURL(file),
status: 'pending',
category,
expectedWidth: width,
expectedHeight: height,
dimChecked: false,
dimOk: false
});
}
if (toAdd.length === 0) return;
files = [...files, ...toAdd];
// Read each video's real dimensions and validate against the spec.
for (const item of toAdd) {
readVideoDimensions(item.file)
.then(({ width, height }) => {
const index = files.findIndex((f) => f.id === item.id);
if (index === -1) return;
const ok = width === item.expectedWidth && height === item.expectedHeight;
files[index].width = width;
files[index].height = height;
files[index].dimChecked = true;
files[index].dimOk = ok;
if (!ok) {
files[index].status = 'error';
files[index].error = `Size ${width}x${height}, expected ${item.expectedWidth}x${item.expectedHeight}`;
}
})
.catch(() => {
const index = files.findIndex((f) => f.id === item.id);
if (index === -1) return;
files[index].dimChecked = true;
files[index].dimOk = false;
files[index].status = 'error';
files[index].error = 'Cannot read video dimensions';
});
}
}
function removeFile(id: string) {
const item = files.find((f) => f.id === id);
if (item) URL.revokeObjectURL(item.preview);
files = files.filter((f) => f.id !== id);
}
function clearAllFiles() {
files.forEach((f) => URL.revokeObjectURL(f.preview));
files = [];
}
// A video that passed name + dimension validation (eligible for push/upload).
function isValidVideo(item: AdvFileItem) {
return item.dimChecked && item.dimOk && item.category !== 'invalid';
}
function isUploadable(item: AdvFileItem) {
return isValidVideo(item) && (item.status === 'pending' || item.status === 'error');
}
const uploadableCount = $derived(files.filter(isUploadable).length);
const validVideoCount = $derived(files.filter(isValidVideo).length);
// Step 1 (mirrors original sync.sh): push the valid videos to the connected
// machine via ADB so you can preview them before sending to the server.
async function pushToMachine() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected (ADB)');
return;
}
const targets = files.filter(isValidVideo);
if (targets.length === 0) {
addNotification('WARN:No valid videos to push');
return;
}
const targetDir = machineAdvDir(selectedCountry);
pushingToMachine = true;
pushProgress = { current: 0, total: targets.length, name: '', percent: 0 };
let success = 0;
try {
for (let i = 0; i < targets.length; i++) {
const item = targets[i];
pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 };
const bytes = new Uint8Array(await item.file.arrayBuffer());
const ok = await adb.pushBinary(
`${targetDir}/${item.file.name}`,
bytes,
(sent, total) => {
pushProgress = {
current: i,
total: targets.length,
name: item.file.name,
percent: total > 0 ? Math.round((sent / total) * 100) : 0
};
}
);
if (ok) {
success++;
pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 };
} else {
addNotification(`ERR:Push failed: ${item.file.name}`);
}
}
if (success > 0) {
addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`);
}
} catch (error) {
console.error('[Adv] push to machine error:', error);
addNotification(`ERR:Push error: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
pushingToMachine = false;
}
}
async function uploadFiles() {
const currentUser = $auth;
if (!currentUser) {
addNotification('ERR:Not logged in');
return;
}
const pendingFiles = files.filter(isUploadable);
if (pendingFiles.length === 0) {
addNotification('WARN:No valid files to upload');
return;
}
const uid = currentUser.uid;
const displayName = currentUser.displayName || 'unknown';
const email = currentUser.email || 'unknown@email.com';
// Method 2 mirrors the original flow: make the machine's adv folder hold
// EXACTLY the selected set (rm -rf + push) BEFORE its ls -l manifest is
// generated — so machine = FTP = manifest. The .mp4 still come from the
// browser (the dragged files), only the manifest comes from the machine.
if (MANIFEST_MODE === 'machine') {
const synced = await syncMachineFolder(pendingFiles);
if (!synced) return;
}
uploading = true;
uploadProgress = { current: 0, total: pendingFiles.length };
for (let i = 0; i < pendingFiles.length; i++) {
const item = pendingFiles[i];
const index = files.findIndex((f) => f.id === item.id);
if (index === -1) continue;
files[index].status = 'uploading';
files[index].error = undefined;
try {
const formData = new FormData();
formData.append('country', selectedCountry);
formData.append('uid', uid);
formData.append('displayName', displayName);
formData.append('email', email);
formData.append('file', item.file);
// Manifest is built from the selected set (method 1) or the machine
// (method 2) — never from the whole FTP folder. So tell the backend
// not to rebuild it from the FTP listing.
formData.append('regenerate', 'false');
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(errorData.detail || errorData.message || 'Upload failed');
}
files[index].status = 'success';
uploadProgress = { current: i + 1, total: pendingFiles.length };
} catch (error) {
files[index].status = 'error';
files[index].error = error instanceof Error ? error.message : 'Unknown error';
console.error(`Upload error for ${item.file.name}:`, error);
}
}
uploading = false;
const successCount = files.filter((f) => f.status === 'success').length;
const errorCount = files.filter((f) => f.status === 'error').length;
if (errorCount === 0) {
addNotification(`INFO:Uploaded ${successCount} adv video(s) successfully`);
} else {
addNotification(`WARN:Uploaded ${successCount}, failed ${errorCount}`);
}
// Build the manifest (sync_1.file) — only from the selected/active set,
// never the whole FTP folder (production keeps many inactive variants there).
if (successCount > 0) {
if (MANIFEST_MODE === 'machine') {
// Method 2: ls -l on the (rm -rf + pushed) machine, then upload.
await generateMachineManifest(uid, displayName, email);
} else {
// Method 1: manifest = the successfully-uploaded files in the UI list.
await uploadSelectedManifest(uid, displayName, email);
}
}
}
// Method 1 — build sync_1.file from the active set the user assembled in the
// UI (the successfully-uploaded files), NOT the whole FTP folder. The FTP keeps
// any other/variant files; the manifest lists only what you chose.
async function uploadSelectedManifest(uid: string, displayName: string, email: string) {
const active = files.filter((f) => f.status === 'success');
if (active.length === 0) return;
generatingManifest = true;
try {
const text = buildManifestText(active);
await uploadManifestText(text, uid, displayName, email);
addNotification(`INFO:Manifest uploaded (${active.length} active file(s))`);
} catch (error) {
console.error('[Adv] selected manifest error:', error);
addNotification(`ERR:Manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
generatingManifest = false;
}
}
// ls -l style line the machine's FileSyncServer parses (size at field 4, name
// at field 7). sync_1.file lists itself as size 0 so the machine skips it.
function buildManifestText(items: AdvFileItem[]): string {
const pad = (n: number) => String(n).padStart(2, '0');
const fmt = (ms: number) => {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const now = Date.now();
const rows = [
{ name: MANIFEST_FILENAME, size: 0, mtime: now },
...items.map((it) => ({
name: it.file.name,
size: it.file.size,
mtime: it.file.lastModified || now
}))
].sort((a, b) => a.name.localeCompare(b.name));
const lines = ['total 0'];
for (const r of rows) {
lines.push(`-rw-rw---- 1 root sdcard_rw ${r.size} ${fmt(r.mtime)} ${r.name}`);
}
return lines.join('\n') + '\n';
}
async function uploadManifestText(
text: string,
uid: string,
displayName: string,
email: string
) {
const fd = new FormData();
fd.append('country', selectedCountry);
fd.append('uid', uid);
fd.append('displayName', displayName);
fd.append('email', email);
fd.append('file', new File([text], MANIFEST_FILENAME, { type: 'text/plain' }));
const res = await fetch(MANIFEST_PROXY_ENDPOINT, { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(e.detail || 'Manifest upload failed');
}
}
// Method 2 step 1 — make the machine's adv folder hold EXACTLY the selected
// files (rm -rf + push), like the original `rm -Rf adv` + push of the whole
// folder. After this, the machine's `ls -l` matches what we upload to the FTP.
// ⚠️ This WIPES the machine's adv folder — method 2 is a full replace, so the
// dragged set must be the complete intended adv set.
async function syncMachineFolder(targets: AdvFileItem[]): Promise<boolean> {
if (!AdbInstance.instance) {
addNotification('ERR:Method 2 (machine manifest) needs the machine connected via ADB');
return false;
}
const advDir = machineAdvDir(selectedCountry);
pushingToMachine = true;
try {
// Wipe + recreate so only the selected set remains on the machine.
await adb.executeCmd(`rm -rf "${advDir}" && mkdir -p "${advDir}"`);
for (let i = 0; i < targets.length; i++) {
const item = targets[i];
pushProgress = { current: i, total: targets.length, name: item.file.name, percent: 0 };
const bytes = new Uint8Array(await item.file.arrayBuffer());
const ok = await adb.pushBinary(
`${advDir}/${item.file.name}`,
bytes,
(sent, total) => {
pushProgress = {
current: i,
total: targets.length,
name: item.file.name,
percent: total > 0 ? Math.round((sent / total) * 100) : 0
};
}
);
if (!ok) {
addNotification(`ERR:Push to machine failed: ${item.file.name}`);
return false;
}
pushProgress = { current: i + 1, total: targets.length, name: item.file.name, percent: 100 };
}
addNotification(`INFO:Machine adv folder synced (${targets.length} file(s))`);
return true;
} catch (error) {
console.error('[Adv] machine sync error:', error);
addNotification(`ERR:Machine sync failed: ${error instanceof Error ? error.message : 'unknown'}`);
return false;
} finally {
pushingToMachine = false;
}
}
// Method 2 step 2 — generate the manifest from the (now synced) machine adv
// folder (`ls -l > sync_1.file`), pull it, and upload it to the FTP.
async function generateMachineManifest(uid: string, displayName: string, email: string) {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected — cannot generate manifest from machine');
return;
}
const advDir = machineAdvDir(selectedCountry);
generatingManifest = true;
try {
// ls -l > sync_1.file on the machine (shell truncates the manifest first,
// so it lists itself as size 0 — exactly what the original flow produces).
await adb.executeCmd(`cd ${advDir} && ls -l > ${MANIFEST_FILENAME}`);
const manifestText = await adb.pull(`${advDir}/${MANIFEST_FILENAME}`);
if (!manifestText || manifestText.trim().length === 0) {
addNotification('ERR:Failed to read sync_1.file from machine');
return;
}
const fd = new FormData();
fd.append('country', selectedCountry);
fd.append('uid', uid);
fd.append('displayName', displayName);
fd.append('email', email);
fd.append(
'file',
new File([manifestText], MANIFEST_FILENAME, { type: 'text/plain' })
);
const res = await fetch(MANIFEST_PROXY_ENDPOINT, { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(e.detail || 'Manifest upload failed');
}
addNotification('INFO:Manifest (from machine) uploaded to server');
} catch (error) {
console.error('[Adv] machine manifest error:', error);
addNotification(`ERR:Machine manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
} finally {
generatingManifest = false;
}
}
$effect(() => {
return () => {
files.forEach((f) => URL.revokeObjectURL(f.preview));
};
});
</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">Adv Upload</h1>
<p class="text-sm text-muted-foreground">Upload advertisement videos (.mp4) to the server</p>
</div>
<div class="flex items-center gap-3">
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
</Badge>
{#if files.length > 0}
<Button
variant="outline"
onclick={clearAllFiles}
disabled={uploading || pushingToMachine}
>
<X class="mr-2 h-4 w-4" />
Clear All
</Button>
{/if}
<!-- Step 1: push to the connected machine to preview -->
<Button
variant="outline"
onclick={pushToMachine}
disabled={pushingToMachine || uploading || !isAdbConnected || validVideoCount === 0}
>
{#if pushingToMachine}
<Spinner class="mr-2 h-4 w-4" />
Pushing {pushProgress.current}/{pushProgress.total}...
{:else}
<MonitorPlay class="mr-2 h-4 w-4" />
Push to Machine ({validVideoCount})
{/if}
</Button>
<!-- Step 2: upload to server -->
<Button
onclick={uploadFiles}
disabled={uploading || pushingToMachine || generatingManifest || uploadableCount === 0}
>
{#if uploading}
<Spinner class="mr-2 h-4 w-4" />
Uploading {uploadProgress.current}/{uploadProgress.total}...
{:else}
<Upload class="mr-2 h-4 w-4" />
Upload ({uploadableCount})
{/if}
</Button>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-8">
<div class="mx-auto max-w-6xl space-y-6">
<!-- Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Upload Settings</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-2 md:max-w-sm">
<Label>Country</Label>
<Select.Root type="single" bind:value={selectedCountry}>
<Select.Trigger class="w-full">
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES as country}
<Select.Item value={country.value}>{country.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- <p class="text-xs text-muted-foreground">
Machine push path: <code class="font-mono">{machineAdvDir(selectedCountry)}</code>
</p> -->
</div>
</Card.Content>
</Card.Root>
<!-- Naming guide -->
<Card.Root>
<Card.Header>
<Card.Title>Naming &amp; Size Rules</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border p-4">
<p class="font-mono text-sm font-semibold">taobin_adv_menu_*.mp4</p>
<p class="text-sm text-muted-foreground">Menu banner ad</p>
<Badge variant="secondary" class="mt-2">1080 × 380</Badge>
</div>
<div class="rounded-lg border p-4">
<p class="font-mono text-sm font-semibold">taobin_adv_*.mp4</p>
<p class="text-sm text-muted-foreground">Fullscreen / Idle ad</p>
<Badge variant="secondary" class="mt-2">1080 × 608</Badge>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Drop Zone -->
<Card.Root>
<Card.Content class="p-6">
<label
class="flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors {dragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
>
<input
type="file"
multiple
accept=".mp4,video/mp4"
class="hidden"
onchange={handleFileSelect}
disabled={uploading}
/>
<Video class="mb-4 h-12 w-12 text-muted-foreground" />
<p class="mb-2 text-lg font-medium">Drop .mp4 videos here or click to browse</p>
<p class="text-sm text-muted-foreground">
Filenames must follow the taobin_adv_*.mp4 convention
</p>
</label>
</Card.Content>
</Card.Root>
<!-- Push-to-Machine Progress -->
{#if pushingToMachine}
<div class="space-y-2">
<Progress
value={pushProgress.total > 0 ? (pushProgress.current / pushProgress.total) * 100 : 0}
max={100}
class="h-2"
/>
<p class="flex items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Spinner class="h-3.5 w-3.5" />
Sending to machine ({pushProgress.current}/{pushProgress.total}): {pushProgress.name}
</p>
</div>
{/if}
<!-- Upload Progress -->
{#if uploading}
<div class="space-y-2">
<Progress
value={uploadProgress.total > 0
? (uploadProgress.current / uploadProgress.total) * 100
: 0}
max={100}
class="h-2"
/>
<p class="text-center text-sm text-muted-foreground">
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
</div>
{/if}
<!-- Generating manifest from machine (method 2) -->
{#if generatingManifest}
<p class="flex items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Spinner class="h-3.5 w-3.5" />
Generating sync_1.file on machine and uploading...
</p>
{/if}
<!-- File List -->
{#if files.length > 0}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center justify-between">
<span>Selected Files ({files.length})</span>
<div class="flex gap-2">
{#if files.some((f) => f.status === 'success')}
<Badge variant="default" class="bg-green-500">
{files.filter((f) => f.status === 'success').length} uploaded
</Badge>
{/if}
{#if files.some((f) => f.status === 'error')}
<Badge variant="destructive">
{files.filter((f) => f.status === 'error').length} invalid/failed
</Badge>
{/if}
</div>
</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each files as item (item.id)}
<div
class="group relative overflow-hidden rounded-lg border bg-muted/30 transition-shadow hover:shadow-md"
>
<!-- Video preview -->
<div class="relative aspect-video bg-black">
<!-- svelte-ignore a11y_media_has_caption -->
<video src={item.preview} class="h-full w-full object-contain" muted controls
></video>
{#if item.status === 'uploading'}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<Spinner class="h-8 w-8 text-white" />
</div>
{:else if item.status === 'success'}
<div
class="absolute inset-0 flex items-center justify-center bg-green-500/20"
>
<CheckCircle class="h-10 w-10 text-green-500" />
</div>
{/if}
<!-- Remove button -->
{#if item.status !== 'uploading'}
<button
class="absolute top-2 right-2 rounded-full bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => removeFile(item.id)}
disabled={uploading}
>
<X class="h-4 w-4" />
</button>
{/if}
</div>
<!-- File info -->
<div class="space-y-1 p-3">
<p class="truncate text-sm font-medium" title={item.file.name}>
{item.file.name}
</p>
<div class="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" class="text-[11px]">
{item.category === 'menu' ? 'Menu banner' : 'Fullscreen'}
</Badge>
{#if !item.dimChecked}
<Badge variant="secondary" class="text-[11px]">Reading…</Badge>
{:else if item.dimOk}
<Badge variant="default" class="bg-green-500 text-[11px]">
{item.width}×{item.height}
</Badge>
{:else}
<Badge variant="destructive" class="text-[11px]">
{item.width ?? '?'}×{item.height ?? '?'}
</Badge>
{/if}
<span class="text-[11px] text-muted-foreground">
{(item.file.size / (1024 * 1024)).toFixed(1)} MB
</span>
</div>
<p class="text-[11px] text-muted-foreground">
Expected {item.expectedWidth}×{item.expectedHeight}
</p>
{#if item.error}
<p class="flex items-center gap-1 text-[11px] text-red-500" title={item.error}>
<AlertCircle class="h-3 w-3 shrink-0" />
<span class="truncate">{item.error}</span>
</p>
{/if}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
</div>
</div>