798 lines
32 KiB
Svelte
798 lines
32 KiB
Svelte
|
|
<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 & 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<N></code> (next free 1–40, 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 & 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<N>.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<N>_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 & 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 & Push{/if}
|
|||
|
|
</Button>
|
|||
|
|
</Dialog.Footer>
|
|||
|
|
</Dialog.Content>
|
|||
|
|
</Dialog.Root>
|