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>
This commit is contained in:
thanawat saiyota 2026-06-30 17:38:59 +07:00
parent 6011b92b7b
commit 47ee23777d
26 changed files with 5582 additions and 539 deletions

View file

@ -27,6 +27,8 @@
if (refPage === 'priceslot') {
await goto(`/sheet/priceslot/${cnt}`);
} else if (refPage === 'price') {
await goto(`/sheet/price/${cnt}`);
} else if (refPage === 'sheet') {
await goto(`/sheet/overview/${cnt}`);
} else {
@ -37,7 +39,7 @@
// read or write permission
let userCurrentPerms = get(currentPerms).filter((x) => {
if (refPage === 'sheet') {
if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');
@ -50,7 +52,7 @@
setTimeout(() => {
// read or write permission
let userCurrentPerms = get(currentPerms).filter((x) => {
if (refPage === 'sheet') {
if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
@ -9,7 +9,16 @@
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import {
materialFromServerQuery,
referenceFromPage,
toppingGroupFromServerQuery,
toppingListFromServerQuery
} from '$lib/core/stores/recipeStore';
import { getRecipes } from '$lib/core/client/server';
import { get } from 'svelte/store';
import { permission } from '$lib/core/stores/permissions';
import { departmentStore } from '$lib/core/stores/departments';
type ToppingListItem = {
id: number;
@ -74,18 +83,27 @@
const sourceDir = '/sdcard/coffeevending';
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
const machineCountryShortPath = '/mnt/sdcard/coffeevending/country/short';
let devRecipe: any = $state(null);
let loadedRecipePath = $state('');
let loadedFromServer = $state(false);
let loading = $state(false);
let saving = $state(false);
let search = $state('');
let activeTab: 'list' | 'group' = $state('list');
let activeTab: 'list' | 'group' = $state('group');
let listDialogOpen = $state(false);
let groupDialogOpen = $state(false);
let deleteConfirmOpen = $state(false);
let pendingDelete: { type: 'list' | 'group'; item: ToppingListItem | ToppingGroup } | null =
$state(null);
let serverCountries: string[] = $state([]);
let selectedServerCountry = $state(get(departmentStore) ?? '');
let machineCountryShort = $state('');
let serverCountryDialogOpen = $state(false);
let machineNotConnectedDialogOpen = $state(false);
let groupToppingDialogOpen = $state(false);
let selectedGroupForToppings: ToppingGroup | null = $state(null);
let listForm: ToppingListForm = $state(createInitialListForm());
let groupForm: ToppingGroupForm = $state(createInitialGroupForm());
@ -107,6 +125,11 @@
let activeGroupCount = $derived(
toppingGroups.filter((group) => (group.inUse as boolean) !== false).length
);
let canWriteToAndroid = $derived(!loadedFromServer && Boolean(adb.getAdbInstance()));
let primaryLanguageLabel = $derived(getPrimaryLanguageLabel(getActiveRecipeCountry()));
let toppingListGridClass = 'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
let toppingGroupGridClass =
'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
let filteredToppingList = $derived(
toppingList.filter((item) => {
const text = `${item.id} ${item.name ?? ''} ${item.otherName ?? ''}`.toLowerCase();
@ -120,6 +143,61 @@
return text.includes(search.toLowerCase());
})
);
function getSupportedServerCountries(permissions: string[]) {
return [
...new Set(
permissions
.filter((item) => item.startsWith('document.read.'))
.map((item) => item.split('.')[2])
.filter(Boolean)
)
].sort();
}
function isThaiCountry(country: string) {
return ['tha', 'thai'].includes(country.trim().toLowerCase());
}
function normalizeCountryShort(country: string) {
return country.trim().toLowerCase();
}
function getActiveRecipeCountry() {
return loadedFromServer ? selectedServerCountry : machineCountryShort;
}
function getPrimaryLanguageLabel(country: string) {
const normalized = country.trim().toUpperCase();
const languageByCountry: Record<string, string> = {
THAI: 'Thai',
THA: 'Thai',
MYS: 'Malay',
IDR: 'Indonesian',
AUS: 'English',
SGP: 'English',
SG: 'English',
UAE_DUBAI: 'Arabic',
DUBAI: 'Arabic',
HKG: 'Chinese',
GBR: 'English',
ROU: 'Romanian',
LVA: 'Latvian',
EST: 'Estonian',
LTU: 'Lithuanian',
USA_PEPSI: 'English'
};
return languageByCountry[normalized] ?? (normalized ? normalized : 'Primary');
}
const unsubscribePermission = permission.subscribe((permissions) => {
serverCountries = getSupportedServerCountries(permissions);
if (!selectedServerCountry && serverCountries.length > 0) {
selectedServerCountry = serverCountries[0];
}
});
let existingListItem = $derived(
toppingList.find((item) => Number(item.id) === Number(listForm.id)) ?? null
);
@ -144,6 +222,25 @@
);
}
function getToppingGroupListIDs(group: ToppingGroup) {
const raw = group.idInGroup ?? '';
if (Array.isArray(raw)) return raw.map(Number).filter(Number.isFinite);
return String(raw)
.split(',')
.map((id) => Number(id.trim()))
.filter(Number.isFinite);
}
function getToppingListsForGroup(group: ToppingGroup) {
const listIDs = new Set(getToppingGroupListIDs(group));
return toppingList.filter((item) => listIDs.has(Number(item.id)));
}
function openGroupToppingDialog(group: ToppingGroup) {
selectedGroupForToppings = group;
groupToppingDialogOpen = true;
}
function createInitialListForm(): ToppingListForm {
return {
id: 1,
@ -195,6 +292,11 @@
}
}
async function loadMachineCountryShort() {
const content = await pullTextWithRetry(machineCountryShortPath, 5000, 1);
return normalizeCountryShort(content ?? '');
}
async function connectAdb() {
try {
if (!adb.getAdbInstance()) {
@ -202,12 +304,114 @@
await adb.connectRecipeMenuViaWebUSB();
}
await loadRecipeFromMachine();
addNotification('INFO:Machine connected');
} catch (error: any) {
addNotification(`ERR:${error?.message ?? error}`);
}
}
async function loadFromMachineSource() {
if (!adb.getAdbInstance()) {
machineNotConnectedDialogOpen = true;
return;
}
await loadRecipeFromMachine();
}
async function waitForServerStore(store: any, timeoutMs = 20000, quietMs = 800) {
const current = get(store);
if (Array.isArray(current) && current.length > 0) {
await new Promise((resolve) => setTimeout(resolve, quietMs));
const latest = get(store);
if (Array.isArray(latest) && latest.length === current.length) return latest;
}
return await new Promise<any[]>((resolve) => {
let quietTimeout: ReturnType<typeof setTimeout> | undefined;
const timeout = setTimeout(() => {
if (quietTimeout) clearTimeout(quietTimeout);
unsubscribe();
const latest = get(store);
resolve(Array.isArray(latest) ? latest : []);
}, timeoutMs);
const unsubscribe = store.subscribe((value: any) => {
if (Array.isArray(value) && value.length > 0) {
if (quietTimeout) clearTimeout(quietTimeout);
quietTimeout = setTimeout(() => {
clearTimeout(timeout);
unsubscribe();
resolve(value);
}, quietMs);
}
});
});
}
async function loadRecipeFromServer() {
if (loading) return;
if (!selectedServerCountry) {
addNotification('ERR:Select country before loading from server');
return;
}
loading = true;
loadedFromServer = false;
machineCountryShort = '';
referenceFromPage.set('topping');
departmentStore.set(selectedServerCountry);
materialFromServerQuery.set([]);
toppingListFromServerQuery.set([]);
toppingGroupFromServerQuery.set([]);
try {
await getRecipes();
const [serverMaterials, serverToppingList, serverToppingGroups] = await Promise.all([
waitForServerStore(materialFromServerQuery),
waitForServerStore(toppingListFromServerQuery),
waitForServerStore(toppingGroupFromServerQuery)
]);
if (serverToppingList.length === 0 || serverToppingGroups.length === 0) {
addNotification('ERR:Cannot fetch topping data from server');
return;
}
devRecipe = {
MaterialSetting: serverMaterials,
Topping: {
ToppingList: serverToppingList,
ToppingGroup: serverToppingGroups
}
};
loadedRecipePath = `Server recipe (${selectedServerCountry})`;
loadedFromServer = true;
setNextListId();
setNextGroupId();
addNotification('INFO:Topping data loaded from server');
} catch (error: any) {
console.error('failed to load toppings from server', error);
addNotification(`ERR:Failed to load toppings from server: ${error?.message ?? error}`);
} finally {
loading = false;
}
}
function openServerCountryDialog() {
if (serverCountries.length === 0) {
addNotification('ERR:No readable countries');
return;
}
serverCountryDialogOpen = true;
}
async function selectServerCountry(country: string) {
selectedServerCountry = country;
serverCountryDialogOpen = false;
await loadRecipeFromServer();
}
async function loadRecipeFromMachine() {
if (loading) return;
if (!adb.getAdbInstance()) {
@ -216,8 +420,12 @@
}
loading = true;
loadedFromServer = false;
referenceFromPage.set('topping');
try {
machineCountryShort = await loadMachineCountryShort();
if (!machineCountryShort) addNotification('WARN:Cannot read machine country short');
for (const recipePath of recipePaths) {
const content = await pullTextWithRetry(recipePath);
if (!content || content.trim().length === 0) continue;
@ -242,6 +450,10 @@
}
async function persistRecipeToAndroid(nextRecipe: any) {
if (!adb.getAdbInstance() || loadedFromServer) {
throw new Error('ADB is required to save recipe changes to Android');
}
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
@ -329,22 +541,24 @@
}
function validateListForm() {
if (!devRecipe) return 'Load recipe from Android first';
if (!devRecipe) return 'Load recipe first';
if (loadedFromServer) return 'Connect ADB before saving topping changes';
if (!Array.isArray(devRecipe?.Topping?.ToppingList)) return 'Recipe has no ToppingList array';
if (!Number.isFinite(Number(listForm.id)) || Number(listForm.id) < 0)
return 'Topping ID is required';
if (!listForm.name.trim()) return 'Thai name is required';
if (!listForm.name.trim()) return `${primaryLanguageLabel} name is required`;
if (!listForm.otherName.trim()) return 'English name is required';
return '';
}
function validateGroupForm() {
if (!devRecipe) return 'Load recipe from Android first';
if (!devRecipe) return 'Load recipe first';
if (loadedFromServer) return 'Connect ADB before saving topping changes';
if (!Array.isArray(devRecipe?.Topping?.ToppingGroup)) return 'Recipe has no ToppingGroup array';
if (!Number.isFinite(Number(groupForm.groupID)) || Number(groupForm.groupID) <= 0) {
return 'Group ID is required';
}
if (!groupForm.name.trim()) return 'Thai group name is required';
if (!groupForm.name.trim()) return `${primaryLanguageLabel} group name is required`;
if (!groupForm.otherName.trim()) return 'English group name is required';
if (!groupForm.idInGroup.trim()) return 'Topping IDs in group are required';
return '';
@ -500,6 +714,11 @@
async function confirmDelete() {
if (!pendingDelete) return;
if (loadedFromServer) {
addNotification('ERR:Connect ADB before deleting topping data');
return;
}
saving = true;
try {
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
@ -531,7 +750,10 @@
onMount(() => {
referenceFromPage.set('topping');
if (adb.getAdbInstance()) void loadRecipeFromMachine();
});
onDestroy(() => {
unsubscribePermission();
});
</script>
@ -551,19 +773,93 @@
{/if}
</div>
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
{#if loading}
<Spinner />
Loading
{:else if devRecipe}
Reload From Android
{:else}
Connect & Load
{/if}
</Button>
<div class="flex flex-col gap-2 rounded-xl border bg-background p-2 shadow-sm">
<div class="px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
Recipe Source
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
class="min-w-32 justify-center self-center text-xs font-medium text-muted-foreground"
onclick={connectAdb}
disabled={loading || saving}
>
<span
class="h-2.5 w-2.5 rounded-full {adb.getAdbInstance()
? 'bg-emerald-500'
: 'bg-destructive'}"
></span>
{adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
</Button>
<Button
variant={!loadedFromServer && devRecipe ? 'default' : 'ghost'}
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
onclick={loadFromMachineSource}
disabled={loading || saving}
>
{#if loading}
<Spinner />
Loading
{:else}
Machine
{/if}
</Button>
<Button
variant={loadedFromServer ? 'default' : 'ghost'}
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
onclick={openServerCountryDialog}
disabled={loading || saving}
>
Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
</Button>
</div>
</div>
</div>
</div>
<Dialog.Root bind:open={serverCountryDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Load Recipe From Server</Dialog.Title>
<Dialog.Description>Select a country to load topping data from server.</Dialog.Description>
</Dialog.Header>
<div class="grid gap-2 py-2">
{#each serverCountries as country}
<Button
variant={country === selectedServerCountry ? 'default' : 'outline'}
class="h-12 justify-start text-base font-semibold"
onclick={() => selectServerCountry(country)}
disabled={loading || saving}
>
{country}
</Button>
{/each}
</div>
<div class="flex justify-end">
<Button variant="outline" onclick={() => (serverCountryDialogOpen = false)}>Cancel</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={machineNotConnectedDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Machine Not Connected</Dialog.Title>
<Dialog.Description>
Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end">
<Button variant="outline" onclick={() => (machineNotConnectedDialogOpen = false)}>OK</Button
>
</div>
</Dialog.Content>
</Dialog.Root>
<div class="grid gap-2 md:grid-cols-3">
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
@ -591,23 +887,23 @@
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<Card.Title>{activeTab === 'list' ? 'Topping List' : 'Topping Group'}</Card.Title>
<Card.Description>
Switch between list items and groups. Edit/Delete actions are explicit per row.
</Card.Description>
<!-- <Card.Description>
Switch between list items and groups. Connect ADB to write changes back to Android.
</Card.Description> -->
</div>
<div class="flex gap-2">
<Button
variant={activeTab === 'list' ? 'default' : 'outline'}
onclick={() => (activeTab = 'list')}
>
ToppingList
</Button>
<Button
variant={activeTab === 'group' ? 'default' : 'outline'}
onclick={() => (activeTab = 'group')}
>
ToppingGroup
</Button>
<Button
variant={activeTab === 'list' ? 'default' : 'outline'}
onclick={() => (activeTab = 'list')}
>
ToppingList
</Button>
</div>
</div>
</Card.Header>
@ -615,12 +911,14 @@
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, code" />
{#if activeTab === 'list'}
<Button onclick={openAddListDialog} disabled={!devRecipe || loading || saving}
>Add Topping</Button
<Button
onclick={openAddListDialog}
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Topping</Button
>
{:else}
<Button onclick={openAddGroupDialog} disabled={!devRecipe || loading || saving}
>Add Group</Button
<Button
onclick={openAddGroupDialog}
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Group</Button
>
{/if}
</div>
@ -629,19 +927,19 @@
{#if loading}
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
<Spinner />
Loading toppings from Android...
Loading toppings...
</div>
{:else if !devRecipe}
<div class="p-4 text-sm text-muted-foreground">Connect and load recipe first.</div>
<div class="p-4 text-sm text-muted-foreground">Load recipe first.</div>
{:else if activeTab === 'list'}
{#if filteredToppingList.length === 0}
<div class="p-4 text-sm text-muted-foreground">No topping list items found.</div>
{:else}
<div
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingListGridClass} md:items-center"
>
<span>ID</span>
<span>Thai Name</span>
<span>Name ({primaryLanguageLabel})</span>
<span>English Name</span>
<span>Use</span>
<span class="text-right">Actions</span>
@ -649,7 +947,7 @@
<div class="grid max-h-[70vh] overflow-auto">
{#each filteredToppingList as item}
<div
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 {toppingListGridClass} md:items-center"
>
<span class="font-mono font-medium text-primary">{item.id}</span>
<span class="font-medium">{item.name || '-'}</span>
@ -662,14 +960,17 @@
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
</span>
<div class="flex gap-2 md:justify-end">
<Button variant="outline" size="sm" onclick={() => loadListIntoForm(item)}
>Edit</Button
<Button
variant="outline"
size="sm"
onclick={() => loadListIntoForm(item)}
disabled={!canWriteToAndroid}>Edit</Button
>
<Button
variant="destructive"
size="sm"
onclick={() => openDeleteConfirm('list', item)}
disabled={saving}
disabled={saving || !canWriteToAndroid}
>
Delete
</Button>
@ -682,10 +983,10 @@
<div class="p-4 text-sm text-muted-foreground">No topping groups found.</div>
{:else}
<div
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingGroupGridClass} md:items-center"
>
<span>ID</span>
<span>Thai Name</span>
<span>Name ({primaryLanguageLabel})</span>
<span>English Name</span>
<span>IDs in group</span>
<span>Use</span>
@ -693,33 +994,52 @@
</div>
<div class="grid max-h-[70vh] overflow-auto">
{#each filteredToppingGroups as group}
<div
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
>
<span class="font-mono font-medium text-primary">{group.groupID}</span>
<span class="font-medium">{group.name || '-'}</span>
<span class="text-muted-foreground">{group.otherName || '-'}</span>
<span class="font-mono text-xs text-muted-foreground">{group.idInGroup || '-'}</span
<div class="border-b">
<div
role="button"
tabindex="0"
class="grid w-full gap-3 p-4 text-left text-sm transition-colors hover:bg-primary/5 {toppingGroupGridClass} md:items-center"
onclick={() => openGroupToppingDialog(group)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') openGroupToppingDialog(group);
}}
>
<span
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !== false
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
>
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
</span>
<div class="flex gap-2 md:justify-end">
<Button variant="outline" size="sm" onclick={() => loadGroupIntoForm(group)}
>Edit</Button
<span class="font-mono font-medium text-primary">{group.groupID}</span>
<span class="font-medium">{group.name || '-'}</span>
<span class="text-muted-foreground">{group.otherName || '-'}</span>
<span class="font-mono text-xs text-muted-foreground"
>{group.idInGroup || '-'}</span
>
<Button
variant="destructive"
size="sm"
onclick={() => openDeleteConfirm('group', group)}
disabled={saving}
<span
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !==
false
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
>
Delete
</Button>
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
</span>
<div class="flex gap-2 md:justify-end">
<Button
variant="outline"
size="sm"
onclick={(event) => {
event.stopPropagation();
loadGroupIntoForm(group);
}}
disabled={!canWriteToAndroid}>Edit</Button
>
<Button
variant="destructive"
size="sm"
onclick={(event) => {
event.stopPropagation();
openDeleteConfirm('group', group);
}}
disabled={saving || !canWriteToAndroid}
>
Delete
</Button>
</div>
</div>
</div>
{/each}
@ -729,11 +1049,70 @@
</Card.Content>
</Card.Root>
<Dialog.Root bind:open={groupToppingDialogOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header>
<Dialog.Title>
ToppingList in Group {selectedGroupForToppings?.groupID ?? ''}
</Dialog.Title>
<Dialog.Description>
{selectedGroupForToppings?.otherName ||
selectedGroupForToppings?.name ||
'Selected group'}
{selectedGroupForToppings?.idInGroup ? ` (${selectedGroupForToppings.idInGroup})` : ''}
</Dialog.Description>
</Dialog.Header>
{#if selectedGroupForToppings}
{@const groupToppings = getToppingListsForGroup(selectedGroupForToppings)}
<div class="grid gap-3 py-2">
{#if groupToppings.length === 0}
<div
class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground"
>
No matching topping list items found for {selectedGroupForToppings.idInGroup ||
'this group'}.
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each groupToppings as toppingItem}
<div class="rounded-md border bg-background p-4 text-sm shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-mono text-xs text-primary">{toppingItem.id}</div>
<div class="mt-1 font-medium">{toppingItem.name || '-'}</div>
<div class="text-muted-foreground">{toppingItem.otherName || '-'}</div>
</div>
<span
class="shrink-0 rounded-full px-2.5 py-1 text-xs {(toppingItem.isUse as boolean) !==
false
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
>
{(toppingItem.isUse as boolean) !== false ? 'Use' : 'Not use'}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (groupToppingDialogOpen = false)}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={listDialogOpen}>
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title>{existingListItem ? 'Edit Topping' : 'Add Topping'}</Dialog.Title>
<Dialog.Description>Manage one item inside <code>ToppingList</code>.</Dialog.Description>
<Dialog.Description>
Manage one item inside <code>ToppingList</code>. Server-loaded data is read-only until ADB
is connected.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
@ -756,7 +1135,7 @@
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="topping-name">Thai Name</Label>
<Label for="topping-name">Name ({primaryLanguageLabel})</Label>
<Input id="topping-name" bind:value={listForm.name} />
</div>
<div class="grid gap-2">
@ -927,7 +1306,7 @@
<Button variant="outline" onclick={() => (listDialogOpen = false)} disabled={saving}
>Cancel</Button
>
<Button onclick={saveToppingListToAndroid} disabled={saving}
<Button onclick={saveToppingListToAndroid} disabled={saving || !canWriteToAndroid}
>{saving ? 'Saving...' : 'Save Topping'}</Button
>
</div>
@ -955,7 +1334,10 @@
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}</Dialog.Title>
<Dialog.Description>Manage one group inside <code>ToppingGroup</code>.</Dialog.Description>
<Dialog.Description>
Manage one group inside <code>ToppingGroup</code>. Server-loaded data is read-only until
ADB is connected.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
@ -978,7 +1360,7 @@
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="group-name">Thai Name</Label>
<Label for="group-name">Name ({primaryLanguageLabel})</Label>
<Input id="group-name" bind:value={groupForm.name} />
</div>
<div class="grid gap-2">
@ -1002,7 +1384,7 @@
<Button variant="outline" onclick={() => (groupDialogOpen = false)} disabled={saving}
>Cancel</Button
>
<Button onclick={saveToppingGroupToAndroid} disabled={saving}
<Button onclick={saveToppingGroupToAndroid} disabled={saving || !canWriteToAndroid}
>{saving ? 'Saving...' : 'Save Group'}</Button
>
</div>
@ -1030,9 +1412,9 @@
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Delete Topping?</Dialog.Title>
<Dialog.Description
>This will remove the selected entry from Android recipe JSON.</Dialog.Description
>
<Dialog.Description>
This will remove the selected entry from Android recipe JSON. ADB is required.
</Dialog.Description>
</Dialog.Header>
{#if pendingDelete}
@ -1062,7 +1444,11 @@
>
Cancel
</Button>
<Button variant="destructive" onclick={confirmDelete} disabled={saving}>
<Button
variant="destructive"
onclick={confirmDelete}
disabled={saving || !canWriteToAndroid}
>
{saving ? 'Deleting...' : 'Delete'}
</Button>
</div>

View file

@ -459,7 +459,7 @@
console.warn('Failed to get boxid from machine:', e);
}
}
requestListMenu(country, boxid);
await requestListMenu(country, boxid);
// Load available product codes from all sources
await loadAvailableProductCodes();

View file

@ -48,7 +48,9 @@
Eye,
Cog,
Upload,
RotateCcw
RotateCcw,
Usb,
MonitorSmartphone
} from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
@ -344,9 +346,84 @@
}
editingItem = JSON.parse(JSON.stringify(item)); // Deep copy
// Translations (name-desc-v2) must show EVERY language configured for the
// country, even ones with no value yet (e.g. tha = English/Thai/China/Myanmar)
// — otherwise empty languages would be filtered out and disappear.
ensureNameDescLanguageCells(editingItem);
// Snapshot which fields are shown at edit-start so they stay editable even
// after the user clears them (otherwise live-filtering by isMeaningfulValue
// would drop a field the moment its text is emptied).
captureEditFieldKeys(editingItem);
isEditMode = true;
}
// name-desc-v2 language columns (sorted), for the Translations card.
function nameDescColumns(): number[] {
return Object.values(nameDescLanguageMap).sort((a, b) => a - b);
}
// Make sure every name/desc section has a cell for each configured language
// column, creating empty ones where missing so all languages are editable.
function ensureNameDescLanguageCells(item: any) {
const cols = nameDescColumns();
for (const section of item?.name_desc_v2 ?? []) {
if (!section?.key?.endsWith('.name') && !section?.key?.endsWith('.desc')) continue;
if (!Array.isArray(section.cells)) section.cells = [];
const row = section.cells[0]?.coord?.row ?? section.row_index;
for (const col of cols) {
if (!section.cells.some((c: SheetCell) => c.coord.col === col)) {
section.cells.push({ value: '', coord: { row, col } });
}
}
}
}
// Translations card: show every language column (existing or just-created).
function getNameDescEditSections(sections: SheetSection[] | undefined): SheetSection[] {
return (
sections?.filter(
(s) => s.key?.endsWith('.name') || s.key?.endsWith('.desc')
) ?? []
);
}
function getNameDescEditCells(section: SheetSection): SheetCell[] {
const cols = nameDescColumns();
return (
section.cells
?.filter((cell) => cols.includes(cell.coord.col))
.sort((a, b) => a.coord.col - b.coord.col) ?? []
);
}
let editFieldKeys = $state<Set<string>>(new Set());
function editFieldKey(section: SheetSection, cell: SheetCell): string {
return `${section.row_index}|${section.key ?? ''}|${cell.coord.col}`;
}
function captureEditFieldKeys(item: any) {
const keys = new Set<string>();
for (const list of [item?.new_layout_v2, item?.name_desc_v2]) {
for (const section of getVisibleSections(list)) {
for (const cell of getVisibleCells(section)) {
keys.add(editFieldKey(section, cell));
}
}
}
editFieldKeys = keys;
}
// Edit-mode variants: show the fields captured at edit-start (not live-filtered),
// so emptying a field doesn't make its input disappear.
function getEditCells(section: SheetSection): SheetCell[] {
return section.cells?.filter((cell) => editFieldKeys.has(editFieldKey(section, cell))) ?? [];
}
function getEditSections(sections: SheetSection[] | undefined): SheetSection[] {
return sections?.filter((section) => getEditCells(section).length > 0) ?? [];
}
function cancelEdit() {
editingItem = null;
isEditMode = false;
@ -525,11 +602,14 @@
en: 'English',
th: 'Thai',
zh: 'Chinese',
zh_hans: 'Mandarin (Simplified)',
zh_hant: 'Mandarin (Traditional)',
ja: 'Japanese',
ms: 'Malay',
my: 'Myanmar',
lt: 'Lithuanian',
ro: 'Romanian'
ro: 'Romanian',
ar: 'Arabic'
};
const languageLabelsByColumn = Object.fromEntries(
Object.entries(languageColumnMap).map(([key, column]) => [
@ -538,6 +618,16 @@
])
) as Record<number, string>;
// name-desc-v2 is a different sheet namespace whose column→language mapping can
// differ from new-layout-v2 → use a separate label map for the Translations card.
const nameDescLanguageMap = columnConfig.nameDescLanguage ?? languageColumnMap;
const nameDescLabelsByColumn = Object.fromEntries(
Object.entries(nameDescLanguageMap).map(([key, column]) => [
column,
languageLabelsByKey[key] ?? key.toUpperCase()
])
) as Record<number, string>;
const nameColumnLabels: Record<number, string> = {
9: 'Hot product codes',
10: 'Cold product codes',
@ -596,13 +686,13 @@
}
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
if (section.key?.endsWith('.name')) {
return languageLabelsByColumn[cell.coord.col]
? `${languageLabelsByColumn[cell.coord.col]} name`
return nameDescLabelsByColumn[cell.coord.col]
? `${nameDescLabelsByColumn[cell.coord.col]} name`
: `Column ${cell.coord.col}`;
}
if (section.key?.endsWith('.desc')) {
return languageLabelsByColumn[cell.coord.col]
? `${languageLabelsByColumn[cell.coord.col]} description`
return nameDescLabelsByColumn[cell.coord.col]
? `${nameDescLabelsByColumn[cell.coord.col]} description`
: `Column ${cell.coord.col}`;
}
return `Column ${cell.coord.col}`;
@ -627,10 +717,33 @@
: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3';
}
// ── Machine (ADB) connect for Preview ────────────────────────────────────
let connectDialogOpen = $state(false);
let adbConnecting = $state(false);
async function connectMachineForPreview() {
adbConnecting = true;
try {
// no Android server socket needed for preview — only file reads
await adb.connnectViaWebUSB(false);
if (AdbInstance.instance) {
addNotification('Machine connected');
connectDialogOpen = false;
// Continue straight into the preview the user originally asked for.
await loadPreviewMenus();
}
} catch (e) {
addNotification('ERR:' + (e instanceof Error ? e.message : 'Connect failed'));
} finally {
adbConnecting = false;
}
}
// Preview functions
async function loadPreviewMenus() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected');
// Prompt the user to connect first (same UX as other pages).
connectDialogOpen = true;
return;
}
@ -1169,6 +1282,20 @@
}
}
// Map a gen-service file path to the machine path, PRESERVING the structure
// under taobin_project. The old code flattened to `${v3}/${basename}`, which sent
// nested files (banners in event_v3/<slug>/, generated .ev in event/, even
// active_promotions.lxml) to the wrong place on the machine.
function genFileToMachinePath(genFilePath: string, countryCode: string): string {
const marker = '/taobin_project/';
const idx = genFilePath.indexOf(marker);
if (idx >= 0) {
return `${sourceDir}/taobin_project/${genFilePath.slice(idx + marker.length)}`;
}
const filename = genFilePath.split('/').pop() || '';
return `${sourceDir}/taobin_project/inter/${countryCode}/xml/multi/v3/${filename}`;
}
async function pushLayoutFilesToAndroid() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected');
@ -1200,9 +1327,9 @@
try {
for (let i = 0; i < filesToPush.length; i++) {
const file = filesToPush[i];
const filename = file.file.split('/').pop() || '';
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
const androidPath = genFileToMachinePath(file.file, country);
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
await adb.executeCmd(`mkdir -p "${parentDir}"`);
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: filesToPush.length };
@ -1236,9 +1363,9 @@
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const filename = file.file.split('/').pop() || '';
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
const androidPath = genFileToMachinePath(file.file, country);
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
await adb.executeCmd(`mkdir -p "${parentDir}"`);
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: files.length };
@ -1470,7 +1597,7 @@
await new Promise((resolve) => setTimeout(resolve, 300));
// Step 1: Enter room via WebSocket (acquire lock)
const entered = enterRoom(country, catalog);
const entered = await enterRoom(country, catalog);
if (!entered) {
addNotification('ERR:WebSocket not connected');
sheetLoading.set(false);
@ -2077,13 +2204,13 @@
</Card.Content>
</Card.Root>
{#if getVisibleSections(editingItem.new_layout_v2).length > 0}
{#if getEditSections(editingItem.new_layout_v2).length > 0}
<Card.Root>
<Card.Header>
<Card.Title>Menu Data</Card.Title>
</Card.Header>
<Card.Content class="space-y-5">
{#each getVisibleSections(editingItem.new_layout_v2) as section}
{#each getEditSections(editingItem.new_layout_v2) as section}
<section class="rounded-md border bg-muted/25 p-4">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">{getSectionTitle(section)}</h3>
@ -2092,7 +2219,7 @@
>
</div>
<div class={getSectionGridClass(section)}>
{#each getVisibleCells(section) as cell}
{#each getEditCells(section) as cell}
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground uppercase">
{getCellLabel(section, cell)}
@ -2111,13 +2238,13 @@
</Card.Root>
{/if}
{#if getVisibleSections(editingItem.name_desc_v2).length > 0}
{#if getNameDescEditSections(editingItem.name_desc_v2).length > 0}
<Card.Root>
<Card.Header>
<Card.Title>Translations</Card.Title>
<Card.Title>Names & Descriptions Topping Page</Card.Title>
</Card.Header>
<Card.Content class="space-y-5">
{#each getVisibleSections(editingItem.name_desc_v2) as section}
{#each getNameDescEditSections(editingItem.name_desc_v2) as section}
<section class="rounded-md border bg-muted/25 p-4">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="truncate font-mono text-sm font-semibold">{section.key}</h3>
@ -2126,7 +2253,7 @@
>
</div>
<div class="grid gap-4 md:grid-cols-2">
{#each getVisibleCells(section) as cell}
{#each getNameDescEditCells(section) as cell}
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground uppercase">
{getCellLabel(section, cell)}
@ -2322,6 +2449,41 @@
</Dialog.Content>
</Dialog.Root>
<!-- Connect machine (ADB) prompt — shown when Preview is clicked while disconnected -->
<Dialog.Root bind:open={connectDialogOpen}>
<Dialog.Content class="sm:max-w-[420px]">
<Dialog.Header>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<MonitorSmartphone class="h-5 w-5" />
</div>
<div>
<Dialog.Title>Connect machine</Dialog.Title>
<Dialog.Description>
Preview reads recipes from the machine. Connect over USB to continue.
</Dialog.Description>
</div>
</div>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (connectDialogOpen = false)} disabled={adbConnecting}>
Cancel
</Button>
<Button onclick={connectMachineForPreview} disabled={adbConnecting}>
{#if adbConnecting}
<Spinner class="mr-2 h-4 w-4" />
Connecting…
{:else}
<Usb class="mr-2 h-4 w-4" />
Connect
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Preview Online Menus Dialog -->
<Dialog.Root bind:open={previewDialogOpen}>
<Dialog.Content class="w-[98vw] sm:max-w-[1600px]">

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { addNotification } from '$lib/core/stores/noti.js';
import { departmentStore } from '$lib/core/stores/departments.js';
import {
findHeaderIndex,
PRICE_HEADER_NAMES_BY_COUNTRY,
sheetPriceAllRows,
sheetPriceHeader,
sheetPriceLoading,
type GristCell
} from '$lib/core/stores/sheetStore.js';
import {
addSheetPrice,
requestAllSheetPrice,
updateSheetPrice
} from '$lib/core/services/sheetService.js';
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { Plus, RefreshCw, Save } from '@lucide/svelte/icons';
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
let search = $state('');
let saving = $state(false);
let newProductCode = $state('');
let newName = $state('');
let newPrice = $state('');
let priceEdits = $state<Record<string, string>>({});
let selectedCountryKey = $derived(selectedCountry.toLowerCase());
let loading = $derived($sheetPriceLoading);
let header = $derived($sheetPriceHeader[selectedCountryKey] ?? []);
let priceColumnIndex = $derived(getPriceColumnIndex());
let rowsByProductCode = $derived($sheetPriceAllRows[selectedCountryKey] ?? {});
let rows = $derived(
Object.values(rowsByProductCode)
.flat()
.sort((a, b) => a.row - b.row)
);
let filteredRows = $derived(
rows.filter((row) => {
const keyword = search.trim().toLowerCase();
if (!keyword) return true;
return row.cells.some((cell) =>
String(cell.value ?? '')
.toLowerCase()
.includes(keyword)
);
})
);
let visibleHeader = $derived(
Array.from(
{ length: Math.max(5, Math.min(12, header.length || 5)) },
(_, index) => header[index] || String.fromCharCode(65 + index)
)
);
onMount(() => {
referenceFromPage.set('price');
if (selectedCountry) departmentStore.set(selectedCountry);
void loadPrice();
});
async function loadPrice() {
if (!selectedCountry) return;
const socket = await waitForOpenSocket();
if (!socket) {
addNotification('ERR:WebSocket not connected');
return;
}
const sent = await requestAllSheetPrice(selectedCountry, true);
if (!sent) addNotification('ERR:Failed to request Price data');
}
function getPriceColumnIndex() {
const headerNames =
PRICE_HEADER_NAMES_BY_COUNTRY[selectedCountryKey] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const col = findHeaderIndex(header, headerNames.cash_price);
return col > 0 ? col : 5;
}
function getCellValue(cells: GristCell[], column: number) {
return String(cells.find((cell) => cell.coord?.col === column)?.value ?? '');
}
function getEditKey(row: { row: number }) {
return String(row.row);
}
function getEditedPrice(row: { row: number; cells: GristCell[] }) {
const key = getEditKey(row);
return priceEdits[key] ?? getCellValue(row.cells, priceColumnIndex);
}
function setEditedPrice(row: { row: number }, value: string) {
priceEdits = { ...priceEdits, [getEditKey(row)]: value };
}
function getChangedUpdates() {
return rows
.map((row) => {
const key = getEditKey(row);
if (!(key in priceEdits)) return null;
const original = getCellValue(row.cells, priceColumnIndex);
const value = priceEdits[key];
if (value === original) return null;
return {
row_index: row.row,
cells: [{ value, coord: { row: row.row, col: priceColumnIndex } }]
};
})
.filter((update): update is NonNullable<typeof update> => update !== null);
}
async function savePriceChanges() {
const updates = getChangedUpdates();
if (updates.length === 0) {
addNotification('INFO:No price changes to save');
return;
}
saving = true;
try {
const sent = await updateSheetPrice(selectedCountry, updates);
if (!sent) {
addNotification('ERR:Failed to send price updates');
return;
}
addNotification(`INFO:Updated ${updates.length} price row(s)`);
priceEdits = {};
await loadPrice();
} finally {
saving = false;
}
}
async function addPriceRow() {
const productCode = String(newProductCode).trim();
const name = String(newName).trim();
const price = String(newPrice).trim();
if (!productCode) {
addNotification('WARN:ProductCode is required');
return;
}
if (!price) {
addNotification('WARN:Price is required');
return;
}
const cells = Array.from({ length: Math.max(header.length, priceColumnIndex) }, () => '');
cells[0] = productCode;
cells[1] = name;
cells[priceColumnIndex - 1] = price;
saving = true;
try {
const sent = await addSheetPrice(selectedCountry, [{ cells }]);
if (!sent) {
addNotification('ERR:Failed to add price row');
return;
}
addNotification(`INFO:Added price row ${productCode}`);
newProductCode = '';
newName = '';
newPrice = '';
await loadPrice();
} finally {
saving = false;
}
}
</script>
<div class="min-h-screen bg-background">
<div class="w-full px-6 py-8 lg:px-8">
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
<div>
<h1 class="text-4xl leading-tight font-bold tracking-normal">
Price [ {selectedCountry.toUpperCase()} ]
</h1>
<p class="mt-4 text-muted-foreground">View main Price sheet data for this country.</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button onclick={savePriceChanges} disabled={saving || getChangedUpdates().length === 0}>
{#if saving}
<Spinner class="mr-2 h-4 w-4" />
Saving
{:else}
<Save class="mr-2 h-4 w-4" />
Save Changes ({getChangedUpdates().length})
{/if}
</Button>
<Button variant="outline" onclick={loadPrice} disabled={loading || saving}>
{#if loading}
<Spinner class="mr-2 h-4 w-4" />
Loading
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
Refresh
{/if}
</Button>
</div>
</div>
<div
class="mb-5 grid gap-3 rounded-xl border bg-card/60 p-4 lg:grid-cols-[1fr_180px_180px_140px_auto]"
>
<Input placeholder="Search product code, name, or price..." bind:value={search} />
<Input placeholder="New ProductCode" bind:value={newProductCode} class="font-mono" />
<Input placeholder="Name" bind:value={newName} />
<Input placeholder="Price" bind:value={newPrice} type="number" min="0" step="0.01" />
<Button variant="outline" onclick={addPriceRow} disabled={saving}>
<Plus class="mr-2 h-4 w-4" />
Add Row
</Button>
</div>
<div class="rounded-xl border bg-card/60">
{#if loading && rows.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
<Spinner class="mr-3 h-6 w-6" />
Loading Price data...
</div>
{:else if rows.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
No Price data loaded. Click Refresh to load data.
</div>
{:else if filteredRows.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
No rows match your search.
</div>
{:else}
<div class="max-h-[calc(100vh-220px)] overflow-auto">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-card">
<Table.Row>
<Table.Head class="w-20">Row</Table.Head>
{#each visibleHeader as column}
<Table.Head>{column}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredRows as row, index (`${row.row}-${row.cells[0]?.value ?? ''}-${index}`)}
<Table.Row>
<Table.Cell class="font-mono text-xs text-muted-foreground">{row.row}</Table.Cell>
{#each visibleHeader as _, index}
<Table.Cell class={index === 0 ? 'font-mono text-sm' : 'text-sm'}>
{#if index + 1 === priceColumnIndex}
<Input
type="number"
min="0"
step="0.01"
class="h-8 w-28 text-right font-semibold"
value={getEditedPrice(row)}
oninput={(event) => setEditedPrice(row, event.currentTarget.value)}
/>
{:else}
{row.cells.find((cell) => cell.coord?.col === index + 1)?.value ?? ''}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -770,6 +770,7 @@
onDestroy(() => {
clearOnMenuSavedCallback();
void adb.goToMachineHome();
});
$effect(() => {

View file

@ -34,12 +34,15 @@
clearOnMenuSavedCallback,
clearMenuSaveState
} from '$lib/core/stores/menuSaveStore';
import { MenuStatus } from '$lib/core/types/menuStatus';
const sourceDir = '/sdcard/coffeevending';
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
const saveResponseTimeoutMs = 60000;
const recipeStepTargetCount = 30;
const toppingSetTargetCount = 13;
// Recipe data from Android
let devRecipe: any | undefined = $state();
@ -49,7 +52,10 @@
// Country detection from Android
let detectedCountry = $state<string>('');
let countryLoading = $state(false);
const detectedCountryCode = $derived(countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || '');
const detectedCountryCode = $derived(
countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || ''
);
const primaryLanguageLabel = $derived(getPrimaryLanguageLabel(detectedCountry));
// ADB connection state
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
@ -81,6 +87,14 @@
let createMenuSaving = $state(false);
let editingDraftProductCode: string | null = $state(null);
let activeTabIndex = $state(0);
let materialPickerOpen = $state(false);
let materialPickerStepIndex: number | null = $state(null);
let materialPickerSearch = $state('');
type ToppingPickerType = 'slot' | 'group' | 'list';
let toppingPickerOpen = $state(false);
let toppingPickerType: ToppingPickerType = $state('slot');
let toppingPickerOptionIndex: number | null = $state(null);
let toppingPickerSearch = $state('');
// Per-temp form data
type TempFormData = {
@ -90,8 +104,6 @@
otherName: string;
description: string;
otherDescription: string;
cashPrice: string;
nonCashPrice: string;
image: string;
isUse: boolean;
recipeSteps: any[];
@ -121,6 +133,8 @@
)
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
);
let groupedMaterialOptions = $derived(getGroupedMaterialOptions());
let toppingPickerOptions = $derived(getToppingPickerOptions());
let activeToppingSlotMaterials = $derived(
(devRecipe?.MaterialSetting ?? [])
@ -163,6 +177,58 @@
};
}
function createPlaceholderRecipeStep() {
return {
MixOrder: 0,
StringParam: '',
FeedParameter: 0,
FeedPattern: 0,
isUse: false,
materialPathId: 0,
powderGram: 0,
powderTime: 0,
stirTime: 0,
syrupGram: 0,
syrupTime: 0,
waterCold: 0,
waterYield: 0
};
}
function getPrimaryLanguageLabel(country: string) {
const normalized = country.trim().toUpperCase();
const languageByCountry: Record<string, string> = {
THAI: 'Thai',
THA: 'Thai',
MYS: 'Malay',
IDR: 'Indonesian',
AUS: 'English',
SGP: 'English',
SG: 'English',
UAE_DUBAI: 'Arabic',
DUBAI: 'Arabic',
HKG: 'Chinese',
GBR: 'English',
ROU: 'Romanian',
LVA: 'Latvian',
EST: 'Estonian',
LTU: 'Lithuanian',
USA_PEPSI: 'English'
};
return languageByCountry[normalized] ?? (normalized ? normalized : 'Local');
}
function getPrimaryNamePlaceholder() {
return primaryLanguageLabel === 'Thai' ? 'ชื่อเมนู' : `Menu name in ${primaryLanguageLabel}`;
}
function getPrimaryDescriptionPlaceholder() {
return primaryLanguageLabel === 'Thai'
? 'คำอธิบายเมนู'
: `Menu description in ${primaryLanguageLabel}`;
}
function createEmptyToppingOption(slot: number | null = null) {
return {
slot,
@ -216,6 +282,77 @@
return `${material.id} - ${material.materialName || material.materialOtherName || 'Unknown'}`;
}
function getMaterialCategory(material: any) {
if (material?.BeanChannel) return 'Bean';
if (material?.PowderChannel) return 'Powder';
if (material?.SyrupChannel) return 'Syrup';
if (material?.FreshSyrupChannel) return 'Fresh Syrup';
if (material?.FrozenFruitChannel) return 'Frozen Fruit';
if (material?.LeavesChannel) return 'Leaves';
if (material?.SodaChannel) return 'Soda';
if (material?.ItemChannel) return 'Item';
if (material?.IsEquipment) return 'Equipment';
return material?.CanisterType || material?.pathOtherName || 'Other';
}
function getGroupedMaterialOptions() {
const search = materialPickerSearch.trim().toLowerCase();
const groups = new Map<string, any[]>();
const categoryOrder = [
'Bean',
'Powder',
'Syrup',
'Fresh Syrup',
'Frozen Fruit',
'Leaves',
'Soda',
'Item',
'Equipment',
'Other'
];
for (const material of activeMaterials) {
const text =
`${material.id} ${material.materialName ?? ''} ${material.materialOtherName ?? ''} ${material.pathOtherName ?? ''} ${material.CanisterType ?? ''}`.toLowerCase();
if (search && !text.includes(search)) continue;
const category = getMaterialCategory(material);
groups.set(category, [...(groups.get(category) ?? []), material]);
}
return [...groups.entries()]
.sort(([left], [right]) => {
const leftIndex = categoryOrder.indexOf(left);
const rightIndex = categoryOrder.indexOf(right);
return (
(leftIndex === -1 ? categoryOrder.length : leftIndex) -
(rightIndex === -1 ? categoryOrder.length : rightIndex) || left.localeCompare(right)
);
})
.map(([category, materials]) => ({ category, materials }));
}
function getSelectedMaterialName(materialPathId: number | null) {
if (materialPathId == null || Number(materialPathId) <= 0) return 'Select material';
const material = activeMaterials.find(
(item: any) => Number(item.id) === Number(materialPathId)
);
return material ? materialDisplayName(material) : `${materialPathId} - Unknown material`;
}
function openMaterialPicker(stepIndex: number) {
materialPickerStepIndex = stepIndex;
materialPickerSearch = '';
materialPickerOpen = true;
}
function selectMaterialForActiveStep(materialPathId: number) {
if (materialPickerStepIndex == null) return;
updateRecipeStepNumber(materialPickerStepIndex, 'materialPathId', String(materialPathId));
materialPickerOpen = false;
materialPickerStepIndex = null;
}
function toppingSlotDisplayName(material: any) {
const slot = Number(material?.id) - 8110;
const slotName = material?.materialOtherName || material?.materialName;
@ -233,6 +370,94 @@
return `${topping?.id ?? '-'} - ${toppingName || 'Unnamed topping'}`;
}
function getSelectedToppingSlotName(slot: number | null) {
if (slot == null || !Number.isFinite(Number(slot))) return 'Select slot';
const material = activeToppingSlotMaterials.find(
(item: any) => Number(item.id) - 8110 === Number(slot)
);
return material ? toppingSlotDisplayName(material) : `Slot ${slot}`;
}
function getSelectedToppingGroupName(groupID: number | null) {
if (groupID == null || !Number.isFinite(Number(groupID))) return 'Select group';
const group = activeToppingGroups.find((item: any) => Number(item.groupID) === Number(groupID));
return group ? toppingGroupDisplayName(group) : `Group ${groupID}`;
}
function getSelectedToppingListName(toppingID: number | null) {
if (toppingID == null || !Number.isFinite(Number(toppingID))) return 'Select topping';
const topping = activeToppingLists.find((item: any) => Number(item.id) === Number(toppingID));
return topping ? toppingListDisplayName(topping) : `Topping ${toppingID}`;
}
function openToppingPicker(type: ToppingPickerType, optionIndex: number) {
toppingPickerType = type;
toppingPickerOptionIndex = optionIndex;
toppingPickerSearch = '';
toppingPickerOpen = true;
}
function getToppingPickerTitle() {
if (toppingPickerType === 'slot') return 'Select Topping Slot';
if (toppingPickerType === 'group') return 'Select Topping Group';
return 'Select Default Topping';
}
function getToppingPickerDescription() {
if (toppingPickerType === 'slot') return 'Choose the physical topping slot for this menu.';
if (toppingPickerType === 'group') return 'Choose the topping group shown to the customer.';
return 'Choose the default selected topping from the selected group.';
}
function getActiveToppingPickerOption() {
if (toppingPickerOptionIndex == null) return undefined;
return activeForm?.toppingOptions?.[toppingPickerOptionIndex];
}
function getToppingPickerOptions() {
const search = toppingPickerSearch.trim().toLowerCase();
const currentOption = getActiveToppingPickerOption();
const options =
toppingPickerType === 'slot'
? activeToppingSlotMaterials.map((material: any) => ({
value: Number(material.id) - 8110,
label: toppingSlotDisplayName(material),
description: material.pathOtherName || material.CanisterType || 'Topping material slot'
}))
: toppingPickerType === 'group'
? activeToppingGroups.map((group: any) => ({
value: Number(group.groupID),
label: toppingGroupDisplayName(group),
description: `${getToppingListsForGroup(Number(group.groupID)).length} toppings`
}))
: getToppingListsForGroup(currentOption?.groupID ?? null).map((topping: any) => ({
value: Number(topping.id),
label: toppingListDisplayName(topping),
description:
topping?.description || topping?.otherDescription || 'Default topping option'
}));
return options.filter((option: any) => {
if (!search) return true;
return `${option.value} ${option.label} ${option.description}`.toLowerCase().includes(search);
});
}
function selectToppingPickerOption(value: number) {
if (toppingPickerOptionIndex == null) return;
if (toppingPickerType === 'slot') {
updateToppingSlot(toppingPickerOptionIndex, String(value));
} else if (toppingPickerType === 'group') {
updateToppingGroup(toppingPickerOptionIndex, String(value));
} else {
updateToppingList(toppingPickerOptionIndex, String(value));
}
toppingPickerOpen = false;
toppingPickerOptionIndex = null;
}
function normalizeToppingListIDs(value: any) {
if (Array.isArray(value)) {
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
@ -315,6 +540,20 @@
}
}
async function reconnectAndroidSocket() {
try {
await adb.reconnectAndroidRecipeMenuServer();
if (isAdbWriterAvailable()) {
addNotification('INFO:Android socket connected');
} else {
addNotification('WARN:Android socket not connected');
}
} catch (error) {
console.error('failed to reconnect android socket', error);
addNotification('WARN:Android socket not connected');
}
}
async function loadRecipeFromMachine() {
if (recipeLoading) return;
@ -364,7 +603,12 @@
// No country file means Thailand
detectedCountry = 'THAI';
}
console.log('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
console.log(
'[CreateMenu] Detected country:',
detectedCountry,
'-> prefix:',
detectedCountryCode
);
} catch (error) {
// Error reading file means Thailand (default)
detectedCountry = 'THAI';
@ -555,8 +799,6 @@
otherName: '',
description: '',
otherDescription: '',
cashPrice: '0',
nonCashPrice: '0',
image: '',
isUse: false,
recipeSteps: [createEmptyRecipeStep()],
@ -742,6 +984,15 @@
).length;
}
function ensureRecipePlaceholders(recipes: any[]) {
return [
...recipes,
...Array.from({ length: Math.max(0, recipeStepTargetCount - recipes.length) }, () =>
createPlaceholderRecipeStep()
)
];
}
function persistStagedMenus() {
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
void persistStagedMenusToAndroid();
@ -751,8 +1002,9 @@
if (!adb.getAdbInstance()) return;
try {
const tempPath = `${stagedMenuAndroidPath}.tmp`;
await adb.push(
stagedMenuAndroidPath,
tempPath,
JSON.stringify(
{
version: 1,
@ -763,6 +1015,8 @@
2
)
);
const result = await adb.executeCmd(`mv ${tempPath} ${stagedMenuAndroidPath}`);
if (result?.error) throw new Error(String(result.error));
} catch (error) {
console.error('failed to persist staged menus to Android', error);
addNotification('WARN:Failed to save draft menus to Android');
@ -811,21 +1065,21 @@
}
function buildMenuFromForm(form: TempFormData) {
const toppingSet = form.toppingOptions
.filter((opt) => opt.slot != null && opt.groupID != null)
.map((opt) => {
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
const listGroupIDs = getToppingGroupListIDs(group);
return {
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
defaultIDSelect: opt.defaultIDSelect ?? 0,
groupID: String(opt.groupID),
isUse: true
};
});
const toppingSet = Array.from({ length: toppingSetTargetCount }, () => createEmptyToppingSet());
for (const opt of form.toppingOptions) {
if (opt.slot == null || opt.groupID == null) continue;
while (toppingSet.length < 4) {
toppingSet.push(createEmptyToppingSet());
const index = opt.slot - 1;
if (index < 0 || index >= toppingSet.length) continue;
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
const listGroupIDs = getToppingGroupListIDs(group);
toppingSet[index] = {
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
defaultIDSelect: opt.defaultIDSelect ?? 0,
groupID: String(opt.groupID),
isUse: true
};
}
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
@ -840,16 +1094,17 @@
}
}
}
const recipes = ensureRecipePlaceholders(recipeSteps);
return {
Description: form.description,
ExtendID: 0,
OnTOP: false,
LastChange: formatAndroidRecipeDate(),
MenuStatus: 0,
MenuStatus: MenuStatus.drafted,
StringParam: ',filter-enable=no,',
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
cashPrice: Number(form.cashPrice) || 0,
cashPrice: 0,
changerecipe: '',
EncoderCount: 0,
id: 0,
@ -857,9 +1112,9 @@
productCode: form.productCode,
name: form.name,
otherName: form.otherName,
nonCashPrice: Number(form.nonCashPrice) || 0,
nonCashPrice: 0,
otherDescription: form.otherDescription,
recipes: recipeSteps,
recipes,
ToppingSet: toppingSet,
SubMenu: [],
total_time: -1,
@ -870,6 +1125,14 @@
};
}
function buildPendingOnlineMenu(menu: any) {
return {
...menu,
MenuStatus: MenuStatus.pendingOnline,
recipes: ensureRecipePlaceholders(menu?.recipes ?? [])
};
}
async function createMenuDraft() {
if (tempForms.length === 0) {
addNotification('ERR:No forms to save');
@ -915,11 +1178,6 @@
async function saveStagedMenuToAndroid(menu: any) {
if (!(await ensureAndroidSocket())) return;
if (getActiveRecipeStepCount(menu) === 0) {
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
addNotification(`ERR:Select at least one material before saving: ${menu.productCode}`);
return;
}
setMenuSaving(menu.productCode);
@ -927,7 +1185,7 @@
type: 'save_recipe_menu_file',
payload: {
time: new Date().toLocaleTimeString(),
data: menu
data: buildPendingOnlineMenu(menu)
}
});
if (!sent) {
@ -961,18 +1219,6 @@
addNotification('WARN:No draft menus ready to save');
return;
}
const invalidMenus = menus.filter((menu) => getActiveRecipeStepCount(menu) === 0);
if (invalidMenus.length > 0) {
for (const menu of invalidMenus) {
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
}
addNotification(
`ERR:Select at least one material before saving: ${invalidMenus
.map((menu) => menu.productCode)
.join(', ')}`
);
return;
}
console.log(
'[Create Menu] save all draft menus',
menus.map((menu) => menu.productCode)
@ -986,7 +1232,7 @@
type: 'save_recipe_menu_file_batch',
payload: {
time: new Date().toLocaleTimeString(),
data: menus
data: menus.map(buildPendingOnlineMenu)
}
});
if (!sent) {
@ -1133,8 +1379,9 @@
const toppingOptionsFromMenu = (m: any) => {
return (m.ToppingSet ?? [])
.filter((ts: any) => ts.isUse && Number(ts.groupID) > 0)
.map((ts: any, index: number) => ({
.map((ts: any, index: number) => ({ ts, index }))
.filter(({ ts }: { ts: any }) => ts.isUse && Number(ts.groupID) > 0)
.map(({ ts, index }: { ts: any; index: number }) => ({
slot: index + 1,
groupID: Number(ts.groupID),
defaultIDSelect: Number(ts.defaultIDSelect) || null
@ -1149,8 +1396,6 @@
otherName: menu.otherName ?? '',
description: menu.Description ?? '',
otherDescription: menu.otherDescription ?? '',
cashPrice: String(menu.cashPrice ?? 0),
nonCashPrice: String(menu.nonCashPrice ?? 0),
image: menu.uriData?.replace(/^img=/, '') ?? '',
isUse: menu.isUse !== false,
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
@ -1191,6 +1436,9 @@
onDestroy(() => {
clearOnMenuSavedCallback();
// Leaving Create Menu: dismiss coffeemain's RecipeActivity and let the
// XMLEngine kiosk home resume (keeps its current portrait orientation).
void adb.goToMachineHome();
});
// Auto-load when ADB is connected
@ -1244,11 +1492,6 @@
<Button variant="default" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
</Button>
{#if !isAndroidSocketConnected}
<Button variant="outline" onclick={() => adb.reconnectAndroidRecipeMenuServer()}
>Reconnect Socket</Button
>
{/if}
{:else}
<Button variant="default" onclick={openSetupPopup}>+ Create New Menu</Button>
<Button variant="outline" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
@ -1256,6 +1499,26 @@
</Button>
{/if}
{#if isAdbConnected}
<Button
variant="outline"
size="sm"
class="justify-center self-center text-xs font-medium text-muted-foreground"
onclick={reconnectAndroidSocket}
disabled={isAndroidSocketConnected}
>
<span
class="h-2.5 w-2.5 rounded-full {isAndroidSocketConnected
? 'bg-emerald-500'
: 'bg-destructive'}"
></span>
{isAndroidSocketConnected ? 'Socket Connected' : 'Reconnect Socket'}
</Button>
{#if !isAndroidSocketConnected}
<span class="self-center text-xs text-muted-foreground">Required before saving</span>
{/if}
{/if}
<!-- Country indicator -->
{#if isAdbConnected}
<div class="ml-auto flex items-center gap-2">
@ -1380,7 +1643,10 @@
{#if detectedCountry}
<div class="rounded-md border bg-muted/30 p-3">
<div class="text-sm text-muted-foreground">Machine Country</div>
<div class="font-semibold">{detectedCountry} <span class="text-muted-foreground">(prefix: {detectedCountryCode})</span></div>
<div class="font-semibold">
{detectedCountry}
<span class="text-muted-foreground">(prefix: {detectedCountryCode})</span>
</div>
</div>
{/if}
@ -1486,11 +1752,11 @@
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for={`name-${activeForm.temp}`}>Name (Thai)</Label>
<Label for={`name-${activeForm.temp}`}>Name ({primaryLanguageLabel})</Label>
<Input
id={`name-${activeForm.temp}`}
value={activeForm.name}
placeholder="ชื่อเมนู"
placeholder={getPrimaryNamePlaceholder()}
oninput={(event) => updateActiveFormField('name', event.currentTarget.value)}
/>
</div>
@ -1506,10 +1772,13 @@
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for={`description-${activeForm.temp}`}>Description (Thai)</Label>
<Label for={`description-${activeForm.temp}`}
>Description ({primaryLanguageLabel})</Label
>
<Input
id={`description-${activeForm.temp}`}
value={activeForm.description}
placeholder={getPrimaryDescriptionPlaceholder()}
oninput={(event) =>
updateActiveFormField('description', event.currentTarget.value)}
/>
@ -1524,28 +1793,7 @@
/>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for={`cashPrice-${activeForm.temp}`}>Cash price</Label>
<Input
id={`cashPrice-${activeForm.temp}`}
type="number"
min="0"
value={activeForm.cashPrice}
oninput={(event) => updateActiveFormField('cashPrice', event.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label for={`nonCashPrice-${activeForm.temp}`}>Non-cash price</Label>
<Input
id={`nonCashPrice-${activeForm.temp}`}
type="number"
min="0"
value={activeForm.nonCashPrice}
oninput={(event) =>
updateActiveFormField('nonCashPrice', event.currentTarget.value)}
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for={`image-${activeForm.temp}`}>Image file</Label>
<Input
@ -1611,23 +1859,14 @@
<div class="grid gap-3">
<div class="grid gap-2">
<Label>Material</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={step.materialPathId == null ? '' : String(step.materialPathId)}
onchange={(event) =>
updateRecipeStepNumber(
index,
'materialPathId',
event.currentTarget.value
)}
<Button
type="button"
variant="outline"
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
onclick={() => openMaterialPicker(index)}
>
<option value="" disabled>Select material</option>
{#each activeMaterials as material}
<option value={String(material.id)}
>{materialDisplayName(material)}</option
>
{/each}
</select>
{getSelectedMaterialName(step.materialPathId)}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-4">
<div class="grid gap-2">
@ -1765,51 +2004,37 @@
<div class="grid gap-3 sm:grid-cols-3">
<div class="grid gap-2">
<Label>Slot</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.slot == null ? '' : String(topping.slot)}
onchange={(e) => updateToppingSlot(index, e.currentTarget.value)}
<Button
type="button"
variant="outline"
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
onclick={() => openToppingPicker('slot', index)}
>
<option value="" disabled>Select slot</option>
{#each activeToppingSlotMaterials as material}
<option value={String(Number(material.id) - 8110)}>
{toppingSlotDisplayName(material)}
</option>
{/each}
</select>
{getSelectedToppingSlotName(topping.slot)}
</Button>
</div>
<div class="grid gap-2">
<Label>Topping group</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.groupID == null ? '' : String(topping.groupID)}
onchange={(e) => updateToppingGroup(index, e.currentTarget.value)}
<Button
type="button"
variant="outline"
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
onclick={() => openToppingPicker('group', index)}
>
<option value="" disabled>Select group</option>
{#each activeToppingGroups as group}
<option value={String(group.groupID)}
>{toppingGroupDisplayName(group)}</option
>
{/each}
</select>
{getSelectedToppingGroupName(topping.groupID)}
</Button>
</div>
<div class="grid gap-2">
<Label>Default topping</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.defaultIDSelect == null
? ''
: String(topping.defaultIDSelect)}
<Button
type="button"
variant="outline"
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
disabled={topping.groupID == null}
onchange={(e) => updateToppingList(index, e.currentTarget.value)}
onclick={() => openToppingPicker('list', index)}
>
<option value="" disabled>Select topping</option>
{#each getToppingListsForGroup(topping.groupID) as toppingItem}
<option value={String(toppingItem.id)}
>{toppingListDisplayName(toppingItem)}</option
>
{/each}
</select>
{getSelectedToppingListName(topping.defaultIDSelect)}
</Button>
</div>
</div>
</div>
@ -1830,6 +2055,96 @@
</Dialog.Content>
</Dialog.Root>
<!-- Material Picker Dialog -->
<Dialog.Root bind:open={materialPickerOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header>
<Dialog.Title>Select Material</Dialog.Title>
<Dialog.Description>
Choose a material for recipe step {materialPickerStepIndex == null
? ''
: materialPickerStepIndex + 1}. Materials are grouped by channel/category.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-2">
<Input bind:value={materialPickerSearch} placeholder="Search material id, name, path, type" />
{#if groupedMaterialOptions.length === 0}
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
No materials found.
</div>
{:else}
<div class="grid max-h-[60vh] gap-4 overflow-y-auto pr-1">
{#each groupedMaterialOptions as group}
<div class="rounded-md border">
<div class="flex items-center justify-between border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">{group.category}</div>
<div class="text-xs text-muted-foreground">{group.materials.length} items</div>
</div>
<div class="grid divide-y">
{#each group.materials as material}
<button
type="button"
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
onclick={() => selectMaterialForActiveStep(Number(material.id))}
>
<div class="font-medium">{materialDisplayName(material)}</div>
<div class="text-xs text-muted-foreground">
{material.pathOtherName || material.CanisterType || 'No path/type'}
</div>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (materialPickerOpen = false)}>Cancel</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Topping Picker Dialog -->
<Dialog.Root bind:open={toppingPickerOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>{getToppingPickerTitle()}</Dialog.Title>
<Dialog.Description>{getToppingPickerDescription()}</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-2">
<Input bind:value={toppingPickerSearch} placeholder="Search id, name, description" />
{#if toppingPickerOptions.length === 0}
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
{toppingPickerType === 'list' ? 'No toppings found for this group.' : 'No options found.'}
</div>
{:else}
<div class="grid max-h-[60vh] divide-y overflow-y-auto rounded-md border">
{#each toppingPickerOptions as option}
<button
type="button"
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
onclick={() => selectToppingPickerOption(option.value)}
>
<div class="font-medium">{option.label}</div>
<div class="text-xs text-muted-foreground">{option.description}</div>
</button>
{/each}
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (toppingPickerOpen = false)}>Cancel</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Brew Confirm Dialog -->
<Dialog.Root bind:open={brewConfirmOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-lg">

View file

@ -0,0 +1,797 @@
<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>