Merge branch 'dev' into 'master'

create branch dev and commit code

See merge request Pakin/supra-app!1
This commit is contained in:
Pakin Tadatangsakul 2026-06-10 08:21:04 +00:00
commit 10be9e1220
44 changed files with 12421 additions and 218 deletions

1097
bun.lock Normal file

File diff suppressed because it is too large Load diff

BIN
bun.lockb

Binary file not shown.

View file

@ -74,7 +74,7 @@
"@yume-chan/scrcpy": "^2.3.0",
"@yume-chan/stream-extra": "^2.5.3",
"animejs": "^4.3.6",
"firebase": "^12.11.0",
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
"usb": "^2.17.0",

5
src/app.d.ts vendored
View file

@ -10,4 +10,9 @@ declare global {
}
}
declare module '*?raw' {
const content: string;
export default content;
}
export {};

View file

@ -0,0 +1,618 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import * as Dialog from '$lib/components/ui/dialog/index';
import {
ANDROID_RECIPE_EXPORT_PATH,
androidRecipeExportPayload,
clearCachedAndroidRecipeExport,
loadAndroidRecipeExportFromDevice,
loadCachedAndroidRecipeExport,
type AndroidRecipeExportData,
type AndroidRecipeExportRow,
type AndroidRecipeExportPayload
} from '$lib/core/services/androidRecipeExportService';
import { adbWriter, sendToAndroid } from '$lib/core/stores/adbWriter';
import { addNotification } from '$lib/core/stores/noti';
import { AdbInstance } from '../../routes/state.svelte';
import { onDestroy, onMount } from 'svelte';
import {
PlugZapIcon,
RefreshCwIcon,
SearchIcon,
Trash2Icon,
UsbIcon
} from '@lucide/svelte/icons';
const PAGE_SIZE = 100;
let search = $state('');
let error = $state('');
let reconnecting = $state(false);
let refreshingExport = $state(false);
let loadingFromDevice = $state(false);
let autoLoadFromDeviceAttempted = $state(false);
let parsingExport = $state(false);
let currentPage = $state(1);
let ingredientDialogOpen = $state(false);
let selectedMenuRow = $state<AndroidRecipeExportRow | null>(null);
let exportPayload = $state<AndroidRecipeExportPayload | null>(null);
let parsed = $state<AndroidRecipeExportData>({
headers: [],
rows: [],
lineCount: 0
});
let cacheLoadTimer: ReturnType<typeof setTimeout> | null = null;
let parseTimer: ReturnType<typeof setTimeout> | null = null;
let parseWorker: Worker | null = null;
let parseRequestId = 0;
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
let versionLabel = $derived(parsed.headers[0] ?? '');
let fieldRow = $derived(parsed.rows[0] ?? null);
let dataRows = $derived(parsed.rows.slice(1));
let filteredRows = $derived(
search.trim()
? dataRows.filter((row) =>
row.cells.some((cell) => cell.toLowerCase().includes(search.trim().toLowerCase()))
)
: dataRows
);
let totalFilteredRows = $derived(filteredRows.length);
let totalPages = $derived(Math.max(1, Math.ceil(totalFilteredRows / PAGE_SIZE)));
let pageStartIndex = $derived((currentPage - 1) * PAGE_SIZE);
let visibleRows = $derived(filteredRows.slice(pageStartIndex, pageStartIndex + PAGE_SIZE));
let lastLoadedAt = $derived(
exportPayload?.exportedAt ? new Date(exportPayload.exportedAt).toLocaleTimeString() : ''
);
let totalRows = $derived(
exportPayload?.lineCount ? Math.max(0, exportPayload.lineCount - 2) : dataRows.length
);
$effect(() => {
search;
currentPage = 1;
});
$effect(() => {
if (currentPage > totalPages) currentPage = totalPages;
if (currentPage < 1) currentPage = 1;
});
$effect(() => {
if (!isAdbConnected) {
autoLoadFromDeviceAttempted = false;
return;
}
if (exportPayload?.content || loadingFromDevice || autoLoadFromDeviceAttempted) return;
autoLoadFromDeviceAttempted = true;
void loadRecipeExportFromAdb(false);
});
function isEmptyCell(cell: string) {
const value = cell.trim();
return !value || value === '-';
}
function getFieldName(cellIndex: number) {
return fieldRow?.cells[cellIndex]?.trim().toLowerCase() ?? '';
}
function isNoteColumn(cellIndex: number) {
return getFieldName(cellIndex) === 'note';
}
function isIngredientColumn(cellIndex: number) {
return cellIndex >= 4;
}
function getMenuIngredients(row: AndroidRecipeExportRow | null) {
if (!row || !fieldRow) return [];
return row.cells
.map((value, index) => ({
name: fieldRow?.cells[index]?.trim() || `Column ${index + 1}`,
value: value.trim()
}))
.filter((item, index) => isIngredientColumn(index) && !isEmptyCell(item.value));
}
function openIngredientDialog(row: AndroidRecipeExportRow) {
selectedMenuRow = row;
ingredientDialogOpen = true;
}
function getDataCellValueClass(cell: string, cellIndex: number) {
const emptyCell = isEmptyCell(cell);
const noteCell = !emptyCell && isNoteColumn(cellIndex);
const ingredientCell = !emptyCell && isIngredientColumn(cellIndex);
const classes = [];
if (emptyCell) classes.push('text-muted-foreground');
if (noteCell) {
classes.push(
'inline-flex',
'items-center',
'rounded-md',
'border',
'border-amber-300',
'bg-amber-100/80',
'px-2',
'py-1',
'font-semibold',
'text-amber-900',
'dark:border-amber-500/45',
'dark:bg-amber-500/15',
'dark:text-amber-100'
);
}
if (ingredientCell) {
classes.push(
'inline-flex',
'items-center',
'rounded-md',
'border',
'border-emerald-400/70',
'bg-emerald-500',
'px-1.5',
'py-0.5',
'text-xs',
'font-mono',
'font-semibold',
'text-white'
);
}
return classes.join(' ');
}
function scheduleParseExport(payload: AndroidRecipeExportPayload | null) {
if (parseTimer) {
clearTimeout(parseTimer);
parseTimer = null;
}
if (!payload?.content) {
parsingExport = false;
parsed = {
headers: [],
rows: [],
lineCount: 0
};
return;
}
parsingExport = true;
parseTimer = setTimeout(() => {
if (!parseWorker) {
parseWorker = new Worker(
new URL('../workers/androidRecipeExport.worker.ts', import.meta.url),
{ type: 'module' }
);
parseWorker.onmessage = (
event: MessageEvent<{
id: number;
parsed?: AndroidRecipeExportData;
error?: string;
}>
) => {
if (event.data.id !== parseRequestId) return;
if (event.data.error) {
error = event.data.error;
parsed = {
headers: [],
rows: [],
lineCount: 0
};
} else if (event.data.parsed) {
parsed = event.data.parsed;
}
parsingExport = false;
};
}
parseRequestId += 1;
parseWorker.postMessage({
id: parseRequestId,
raw: payload.content,
maxRows: Number.POSITIVE_INFINITY
});
parseTimer = null;
}, 0);
}
async function reconnectAdb() {
reconnecting = true;
error = '';
try {
const adb = await import('$lib/core/adb/adb');
if (adb.getAdbInstance()) {
await adb.reconnectAndroidRecipeMenuServer();
} else {
await adb.connnectViaWebUSB(false);
}
if (adb.getAdbInstance()) {
addNotification('INFO:ADB connected');
}
} catch (e: any) {
error = e?.message ?? 'Unable to connect ADB device.';
addNotification(`ERR:${error}`);
} finally {
reconnecting = false;
}
}
function clearExportData() {
clearCachedAndroidRecipeExport();
error = '';
}
async function refreshRecipeExport() {
if (!isAndroidSocketConnected) {
await loadRecipeExportFromAdb();
return;
}
refreshingExport = true;
error = '';
addNotification('INFO:Refreshing recipe data');
await sendToAndroid({
type: 'recipe-export-refresh',
payload: {}
});
}
async function loadRecipeExportFromAdb(showSuccess = true) {
if (!isAdbConnected) {
error = 'Connect ADB before loading recipe export.';
return;
}
loadingFromDevice = true;
error = '';
try {
await loadAndroidRecipeExportFromDevice();
if (showSuccess) addNotification('INFO:Recipe export loaded from Android file');
} catch (e: any) {
error =
e?.message ??
`Unable to pull ${ANDROID_RECIPE_EXPORT_PATH}. Generate the export on Android first.`;
addNotification(`ERR:${error}`);
} finally {
loadingFromDevice = false;
}
}
const unsubscribeRecipeExport = androidRecipeExportPayload.subscribe((payload) => {
exportPayload = payload;
if (payload?.content) refreshingExport = false;
scheduleParseExport(payload);
});
onMount(() => {
cacheLoadTimer = setTimeout(() => {
loadCachedAndroidRecipeExport().then((payload) => {
if (!payload?.content && isAdbConnected) {
void loadRecipeExportFromAdb(false);
}
});
}, 50);
});
onDestroy(() => {
if (cacheLoadTimer) clearTimeout(cacheLoadTimer);
if (parseTimer) clearTimeout(parseTimer);
parseWorker?.terminate();
unsubscribeRecipeExport();
});
</script>
<div class="mx-auto flex w-full max-w-[1600px] flex-col gap-6 px-8 py-8">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-2">
<div class="flex items-center gap-3">
<h1 class="text-4xl font-bold tracking-normal">Android Recipe Export</h1>
<Badge variant={isAdbConnected ? 'default' : 'destructive'} class="gap-1">
<UsbIcon class="size-3.5" />
{isAdbConnected ? 'ADB Connected' : 'ADB Offline'}
</Badge>
{#if isAdbConnected}
<Badge variant={isAndroidSocketConnected ? 'default' : 'secondary'} class="gap-1">
<PlugZapIcon class="size-3.5" />
{isAndroidSocketConnected ? 'Socket Connected' : 'ADB File Mode'}
</Badge>
{/if}
</div>
<p class="text-muted-foreground">
Receive exported recipe data from Machine.
{#if lastLoadedAt}
<span class="ml-2">Last loaded {lastLoadedAt}</span>
{/if}
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="relative w-full min-w-64 sm:w-80">
<SearchIcon class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<input
class="h-10 w-full rounded-md border border-input bg-background pr-3 pl-9 text-sm transition-colors outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-[3px] focus:ring-ring/50"
placeholder="Search rows"
bind:value={search}
/>
</div>
{#if isAdbConnected}
<Button onclick={refreshRecipeExport} disabled={refreshingExport || loadingFromDevice}>
<RefreshCwIcon
class="size-4 {refreshingExport || loadingFromDevice ? 'animate-spin' : ''}"
/>
{refreshingExport || loadingFromDevice
? 'Reloading'
: isAndroidSocketConnected
? 'Refresh'
: 'Reload File'}
</Button>
{/if}
{#if exportPayload}
<Button variant="outline" onclick={clearExportData}>
<Trash2Icon class="size-4" />
Clear
</Button>
{/if}
{#if !isAdbConnected}
<Button onclick={reconnectAdb} disabled={reconnecting}>
<PlugZapIcon class="size-4 {reconnecting ? 'animate-pulse' : ''}" />
{reconnecting ? 'Connecting' : 'Reconnect ADB'}
</Button>
{/if}
</div>
</div>
{#if error}
<div
class="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-destructive"
>
{error}
</div>
{/if}
{#if parsed.headers.length > 0}
<div class="overflow-hidden rounded-lg border border-border bg-card">
<div class="border-b border-border px-4 py-3 text-sm text-muted-foreground">
{#if totalFilteredRows !== totalRows}
Showing {visibleRows.length} of {totalFilteredRows} matching rows. Page {currentPage} of
{totalPages}.
{:else}
Showing rows {pageStartIndex + 1}-{pageStartIndex + visibleRows.length} of {totalRows}.
Page {currentPage} of {totalPages}.
{/if}
</div>
<div class="max-h-[70vh] overflow-auto">
<div
class="sticky top-0 left-0 z-40 border-b border-border bg-card px-4 py-3 text-sm font-semibold"
>
{versionLabel}
</div>
<table class="w-max min-w-full border-separate border-spacing-0 text-sm">
{#if fieldRow}
<thead>
<tr>
{#each fieldRow.cells as cell, cellIndex}
<th
class="sticky top-[45px] z-20 border-b border-border bg-card px-4 py-3 text-left font-semibold whitespace-nowrap"
class:left-0={cellIndex === 0}
class:z-30={cellIndex === 0}
class:min-w-64={cellIndex === 0}
class:min-w-48={cellIndex !== 0}
>
{cell || `Column ${cellIndex + 1}`}
</th>
{/each}
</tr>
</thead>
{/if}
<tbody>
{#each visibleRows as row}
<tr class="border-b border-border">
{#each row.cells as cell, cellIndex}
{@const emptyCell = isEmptyCell(cell)}
{@const ingredientCell = !emptyCell && isIngredientColumn(cellIndex)}
<td
class="border-b border-border bg-card px-4 py-3 whitespace-nowrap"
class:sticky={cellIndex === 0}
class:left-0={cellIndex === 0}
class:z-10={cellIndex === 0}
class:min-w-64={cellIndex === 0}
class:min-w-48={cellIndex !== 0}
class:text-center={ingredientCell}
>
{#if cellIndex === 0}
<button
type="button"
class="flex w-full items-center rounded-md border border-border bg-muted/45 px-3 py-2 text-left font-medium transition-colors hover:border-primary/45 hover:bg-primary/15 focus:ring-2 focus:ring-ring focus:outline-none"
onclick={() => openIngredientDialog(row)}
>
<span class="truncate">{cell || '-'}</span>
</button>
{:else}
<span class={getDataCellValueClass(cell, cellIndex)}>{cell || '-'}</span>
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
<p class="text-sm text-muted-foreground">
Page {currentPage} / {totalPages}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => (currentPage = Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onclick={() => (currentPage = Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
Next
</Button>
</div>
</div>
</div>
{:else if !isAdbConnected}
<div class="rounded-lg border border-border bg-card p-8">
<h2 class="text-xl font-semibold">Connect Android board first</h2>
<p class="mt-2 text-muted-foreground">
Connect ADB to load the exported recipe file from Android.
</p>
<Button class="mt-6" onclick={reconnectAdb} disabled={reconnecting}>
<PlugZapIcon class="size-4 {reconnecting ? 'animate-pulse' : ''}" />
{reconnecting ? 'Connecting' : 'Reconnect ADB'}
</Button>
</div>
{:else if !isAndroidSocketConnected}
<div class="rounded-lg border border-border bg-card p-8">
<h2 class="text-xl font-semibold">Load exported recipe file</h2>
<p class="mt-2 text-muted-foreground">
ADB is connected. Load the latest exported file from
<code class="font-mono">{ANDROID_RECIPE_EXPORT_PATH}</code>.
</p>
<div class="mt-6 flex flex-wrap gap-3">
<Button onclick={() => loadRecipeExportFromAdb()} disabled={loadingFromDevice}>
<RefreshCwIcon class="size-4 {loadingFromDevice ? 'animate-spin' : ''}" />
{loadingFromDevice ? 'Loading' : 'Reload File'}
</Button>
</div>
</div>
{:else if parsingExport}
<div class="rounded-lg border border-border bg-card p-8">
<h2 class="text-xl font-semibold">Preparing recipe table</h2>
<p class="mt-2 text-muted-foreground">Recipe export received. Preparing the table pages.</p>
</div>
{:else if parsed.headers.length === 0}
<div class="rounded-lg border border-border bg-card p-8">
<h2 class="text-xl font-semibold">Preparing recipe data</h2>
<p class="mt-2 text-muted-foreground">Please wait while the latest recipe list is loading.</p>
</div>
{/if}
</div>
<Dialog.Root bind:open={ingredientDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
{@const selectedIngredients = getMenuIngredients(selectedMenuRow)}
<!-- Header -->
<div class="flex items-start gap-4 border-b border-border pb-4">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-emerald-500/15"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-emerald-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg leading-tight font-semibold">
{selectedMenuRow?.cells[0] || 'Menu'}
</h2>
<p class="mt-1 text-sm text-muted-foreground">
{#if selectedIngredients.length > 0}
{selectedIngredients.length} ingredient{selectedIngredients.length > 1 ? 's' : ''} used
{:else}
No ingredients
{/if}
</p>
</div>
</div>
<!-- Ingredient List -->
{#if selectedIngredients.length > 0}
<div class="mt-4 max-h-[50vh] space-y-2 overflow-auto pr-1">
{#each selectedIngredients as ingredient, i}
<div
class="group flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-emerald-500/30 hover:bg-emerald-500/5"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-bold text-muted-foreground"
>
{i + 1}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{ingredient.name}</p>
</div>
<div class="shrink-0">
<span
class="inline-flex items-center rounded-md bg-emerald-500 px-2.5 py-1 font-mono text-sm font-semibold text-white shadow-sm"
>
{ingredient.value}
</span>
</div>
</div>
{/each}
</div>
{:else}
<div
class="mt-6 flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/20 px-4 py-8"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
</div>
<p class="mt-3 text-sm font-medium text-muted-foreground">No ingredients found</p>
<p class="mt-1 text-xs text-muted-foreground/70">This menu has no ingredient values</p>
</div>
{/if}
<!-- Footer -->
<div class="mt-6 flex justify-end">
<Button variant="outline" onclick={() => (ingredientDialogOpen = false)}>Close</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View file

@ -3,9 +3,7 @@
import { onDestroy, type ComponentProps } from 'svelte';
import { asset } from '$app/paths';
import AppAccountSelect from './app-account-select.svelte';
import { needPermission } from '$lib/core/handlers/permissionHandler';
import {
Code,
LayoutDashboard,
LucideEye,
CherryIcon,
@ -13,21 +11,43 @@
BugIcon,
CupSodaIcon,
Shield,
FileSpreadsheet
FileSpreadsheet,
MonitorSmartphone,
PlusCircle,
ImageUp,
Video,
Sun,
Moon
} from '@lucide/svelte/icons';
import TaobinLogo from '$lib/assets/logo.svelte';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/button/button.svelte';
import { sidebarStore } from '$lib/core/stores/sidebar';
import { auth } from '$lib/core/stores/auth';
import { permission as permissionStore } from '$lib/core/stores/permissions';
import { isUserAdmin } from '$lib/core/admin/adminService';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import { env } from '$env/dynamic/public';
import { toggleMode, mode } from 'mode-watcher';
let sideBar: HTMLElement | null = $state(null);
let isSideBarOpen: boolean = $state(true);
let isAdmin: boolean = $state(false);
// Helper function to check permission (reactive version)
function checkPermission(requiredPerm: string, userPerms: string[]): boolean {
if (!requiredPerm) return true;
const reqParts = requiredPerm.split('.');
return userPerms.some((userPerm) => {
const userParts = userPerm.split('.');
if (userParts.length !== reqParts.length) return false;
return reqParts.every(
(part, i) => part === '*' || userParts[i] === '*' || part === userParts[i]
);
});
}
const app_version = env.PUBLIC_APP_VERSION;
const data = {
@ -75,11 +95,35 @@
icon: CupSodaIcon,
requirePerm: ''
},
{
title: 'Create Menu',
url: '/tools/create-menu',
icon: PlusCircle,
requirePerm: ''
},
{
title: 'Debug',
url: '/tools/debug',
icon: BugIcon,
requirePerm: ''
},
{
title: 'Android Recipes',
url: '/tools/android-recipe',
icon: MonitorSmartphone,
requirePerm: ''
},
{
title: 'Image Upload',
url: '/tools/image-upload',
icon: ImageUp,
requirePerm: ''
},
{
title: 'Adv Upload',
url: '/tools/adv-upload',
icon: Video,
requirePerm: ''
}
]
},
@ -136,13 +180,13 @@
unsubSidebar();
});
// Reactive: re-filter when $permissionStore changes
let authorizedNavMain = $derived(
data.navMain
.map((nav) => {
const filteredItems = nav.items.filter((item) => {
if (!item.requirePerm) return true;
return needPermission(item.requirePerm);
return checkPermission(item.requirePerm, $permissionStore);
});
return { ...nav, items: filteredItems };
@ -160,8 +204,8 @@
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<div class="flex items-center justify-center">
<button class="hover:cursor-pointer" onclick={onClickLogoIcon}>
<TaobinLogo size={isSideBarOpen ? 96 : 24} fillColor={'#FFFFFF'} />
<button class="text-sidebar-foreground hover:cursor-pointer" onclick={onClickLogoIcon}>
<TaobinLogo size={isSideBarOpen ? 96 : 24} fillColor={'currentColor'} />
</button>
</div>
<p class="justify-center text-center font-mono text-[8px] text-muted-foreground">
@ -212,17 +256,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a
href={sub.url}
{...props}
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
referenceFromPage.set('sheet');
goto(sub.url);
}
}}
>
<a href={sub.url} {...props}>
{#if sub.icon}
<sub.icon />
{/if}
@ -238,6 +272,26 @@
{/if}
</Sidebar.Content>
<Sidebar.Footer>
<div class="flex items-center gap-2 px-2 pb-2">
<Button
variant="ghost"
size="icon"
onclick={toggleMode}
class="h-8 w-8 shrink-0"
aria-label="Toggle theme"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
{#if isSideBarOpen}
<span class="text-xs text-muted-foreground">
{mode.current === 'dark' ? 'Dark' : 'Light'}
</span>
{/if}
</div>
<AppAccountSelect />
</Sidebar.Footer>
</Sidebar.Root>

View file

@ -112,7 +112,14 @@
async function getCurrentQueue() {
let inst = adb.getAdbInstance();
if (inst) {
let current_brewing = await adb.pull(env.PUBLIC_BREW_CURRENT_RECIPE);
const currentRecipePath = env.PUBLIC_BREW_CURRENT_RECIPE;
if (!currentRecipePath) {
return {
error: 'PUBLIC_BREW_CURRENT_RECIPE is not configured'
};
}
let current_brewing = await adb.pull(currentRecipePath);
// console.log(`current brewing queue: ${current_brewing}`);
if (current_brewing === '') {
current_brewing = '{}';

View file

@ -298,6 +298,21 @@
}
}
async function saveRecipeMenuFileToAndroid() {
if (refPage != 'brew') return;
const recipeMenu = ready_to_send_brew[0] ?? $state.snapshot(currentData);
if (!recipeMenu?.productCode) {
addNotification('ERR:Recipe data is not ready to save');
return;
}
const sent = await adb.sendRecipeMenuFileToAndroid(recipeMenu);
if (sent) {
addNotification(`INFO:Save recipe menu file request sent: ${recipeMenu.productCode}`);
}
}
function onCloseDialog() {
currentEditingRecipeProductCode.set('');
callback_revert_value_if_not_save(save_change);
@ -404,6 +419,7 @@
save_change = true;
callback_revert_value_if_not_save(save_change);
await saveRecipeMenuFileToAndroid();
addNotification('INFO:Save recipe');
}}

View file

@ -14,9 +14,53 @@ import { handleAdbPayload } from '../handlers/adbPayloadHandler';
import { adbWriter } from '../stores/adbWriter';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
import type Dice_2 from '@lucide/svelte/icons/dice-2';
import { get } from 'svelte/store';
let syncConnection: any = null;
let syncOperation: Promise<unknown> = Promise.resolve();
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
let recipeMenuAndroidServerRetryTimer: ReturnType<typeof setTimeout> | null = null;
async function runSyncOperation<T>(operation: () => Promise<T>) {
const run = syncOperation.then(operation, operation);
syncOperation = run.catch(() => {});
return await run;
}
function clearRecipeMenuAndroidServerRetry() {
if (recipeMenuAndroidServerRetryTimer) {
clearTimeout(recipeMenuAndroidServerRetryTimer);
recipeMenuAndroidServerRetryTimer = null;
}
}
function scheduleRecipeMenuAndroidServerReconnect(delayMs = 2000) {
if (recipeMenuAndroidServerRetryTimer || !getAdbInstance()) return;
recipeMenuAndroidServerRetryTimer = setTimeout(() => {
recipeMenuAndroidServerRetryTimer = null;
void connectToAndroidRecipeMenuServer(false);
}, delayMs);
}
async function connectRecipeMenuWebUsbDevice(
device: AdbDaemonWebUsbDevice,
credentialStore: AdbWebCredentialStore
) {
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credentialStore
});
const adb = new Adb(transport);
await saveAdbInstance(adb);
await connectToAndroidRecipeMenuServer();
return adb;
}
function isRecoverableError(error: any): boolean {
if (!error) return false;
@ -91,7 +135,7 @@ async function connectWithRetry<T>(
);
}
export async function connnectViaWebUSB() {
export async function connnectViaWebUSB(connectAndroidServer = true) {
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('usb ok', globalThis.navigator.usb);
if (device) {
@ -109,7 +153,9 @@ export async function connnectViaWebUSB() {
const adb = new Adb(transport);
await saveAdbInstance(adb);
await connectToAndroidServer();
if (connectAndroidServer) {
await connectToAndroidServer();
}
// save device info
await deviceCredentialManager.saveDeviceInfo(device);
@ -129,7 +175,8 @@ export async function connnectViaWebUSB() {
export async function connectDeviceByCred(
device: AdbDaemonWebUsbDevice,
credStore: AdbWebCredentialStore
credStore: AdbWebCredentialStore,
connectAndroidServer = true
) {
try {
const connection = await device.connect();
@ -142,7 +189,9 @@ export async function connectDeviceByCred(
const adb = new Adb(transport);
await saveAdbInstance(adb);
await connectToAndroidServer();
if (connectAndroidServer) {
await connectToAndroidServer();
}
return true;
} catch (error) {
@ -159,6 +208,112 @@ export function getAdbInstance() {
return AdbInstance.instance;
}
export async function connectRecipeMenuViaWebUSB() {
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
return currentInstance;
}
if (recipeMenuAdbConnectPromise) return await recipeMenuAdbConnectPromise;
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('recipe menu usb ok', 'usb' in globalThis.navigator);
if (device) {
console.log('recipe menu connect ', device.name);
try {
const credentialStore = new AdbWebCredentialStore();
recipeMenuAdbConnectPromise = connectRecipeMenuWebUsbDevice(device, credentialStore);
const adb = await recipeMenuAdbConnectPromise;
await deviceCredentialManager.saveDeviceInfo(device);
return adb;
} catch (e: any) {
console.error('recipe menu connect error', e);
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
'ERR:Device is already in use by another program, please close the program and try again'
);
}
throw e;
} finally {
recipeMenuAdbConnectPromise = null;
}
}
}
export async function connectRecipeMenuDeviceByCred(
device: AdbDaemonWebUsbDevice,
credStore: AdbWebCredentialStore
) {
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
return true;
}
if (recipeMenuAdbConnectPromise) {
await recipeMenuAdbConnectPromise;
return Boolean(getAdbInstance());
}
try {
recipeMenuAdbConnectPromise = connectRecipeMenuWebUsbDevice(device, credStore);
await recipeMenuAdbConnectPromise;
return true;
} catch (error) {
throw error;
} finally {
recipeMenuAdbConnectPromise = null;
}
}
export async function reconnectAndroidRecipeMenuServer() {
await connectToAndroidRecipeMenuServer(true);
}
export async function sendRecipeMenuFileToAndroid(recipe: any) {
return await sendRecipeMenuMessageToAndroid({
type: 'save_recipe_menu_file',
payload: {
time: new Date().toLocaleTimeString(),
data: recipe
}
});
}
export async function sendRecipeMenuMessageToAndroid(message: any) {
let writer: any = get(adbWriter);
if (!writer) {
if (getAdbInstance()) {
await connectToAndroidRecipeMenuServer(false);
} else {
await connectRecipeMenuViaWebUSB();
}
writer = get(adbWriter);
}
if (!writer) {
addNotification('ERR:No active Android recipe connection');
return false;
}
try {
const encoder = new TextEncoder();
await writer.write(encoder.encode(JSON.stringify(message) + '\n'));
console.log('recipe menu sent! ', JSON.stringify(message).length);
return true;
} catch (error) {
console.error('recipe menu write failed', error);
addNotification(`ERR:Failed to send recipe menu\n${error}`);
return false;
}
}
export async function executeCmd(command: string) {
let instance = getAdbInstance();
@ -232,64 +387,117 @@ export async function cleanupSync() {
}
export async function pull(filename: string, timeoutMs: number = 5000) {
let instance = getAdbInstance();
return await runSyncOperation(async () => {
let instance = getAdbInstance();
await cleanupSync();
try {
if (instance) {
let chunkList: Uint8Array<ArrayBufferLike>[] = [];
const syncProm = instance.sync();
const timeoutProm = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('sync timeout')), timeoutMs);
});
syncConnection = await Promise.race([syncProm, timeoutProm]);
const content = syncConnection.read(filename);
let result_string = '';
for await (const chunk of content) {
result_string += new TextDecoder().decode(chunk);
}
return result_string;
}
} catch (pull_error: any) {
console.log('pulling error', pull_error);
} finally {
await cleanupSync();
}
try {
if (instance) {
let chunkList: Uint8Array<ArrayBufferLike>[] = [];
const syncProm = instance.sync();
const timeoutProm = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('sync timeout')), timeoutMs);
});
syncConnection = await Promise.race([syncProm, timeoutProm]);
const content = syncConnection.read(filename);
let result_string = '';
for await (const chunk of content) {
result_string += new TextDecoder().decode(chunk);
}
return result_string;
}
} catch (pull_error: any) {
console.log('pulling error', pull_error);
} finally {
await cleanupSync();
}
});
}
export async function push(path: string, obj: string) {
let instance = getAdbInstance();
if (instance) {
let sync = await instance.sync();
const encoder = new TextEncoder();
return await runSyncOperation(async () => {
let instance = getAdbInstance();
if (instance) {
let sync = await instance.sync();
const encoder = new TextEncoder();
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(encoder.encode(obj)));
controller.close();
}
});
try {
console.log('support push v2', sync.supportsSendReceiveV2);
await sync.write({
filename: path,
file
});
} catch (error) {
console.log('error while trying to write to machine', error);
} finally {
await sync.dispose();
}
}
});
}
// Push a binary file (e.g. an .mp4 video) to the machine. Unlike push() which
// text-encodes a string, this streams raw bytes in chunks so binary data is not
// corrupted, and reports progress.
export async function pushBinary(
path: string,
data: Uint8Array,
onProgress?: (sent: number, total: number) => void
): Promise<boolean> {
return await runSyncOperation(async () => {
let instance = getAdbInstance();
if (!instance) return false;
const total = data.byteLength;
onProgress?.(0, total);
let sync = await instance.sync();
// Mirror the working text push(): a single enqueue then close. @yume-chan
// packetizes internally for the ADB protocol; a multi-chunk or pull-based
// stream can stall the transfer.
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(encoder.encode(obj)));
controller.enqueue(data);
controller.close();
}
});
try {
console.log('support push v2', sync.supportsSendReceiveV2);
await sync.write({
filename: path,
file
});
const writeProm = sync.write({ filename: path, file });
// Safety net so a stalled transfer can't hang the UI forever.
const timeoutProm = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('push write timeout (120s)')), 120000)
);
await Promise.race([writeProm, timeoutProm]);
onProgress?.(total, total);
return true;
} catch (error) {
console.log('error while trying to write to machine', error);
console.log('error while pushing binary to machine', error);
return false;
} finally {
await sync.dispose();
}
}
});
}
// NOTE: adb reverse is not work by unavailable features support
export async function reconnectAndroidServer() {
await connectToAndroidServer();
}
async function connectToAndroidServer(maxRetries = 5) {
let lastError: any;
for (let attempt = 0; attempt < maxRetries; attempt++) {
@ -300,10 +508,12 @@ async function connectToAndroidServer(maxRetries = 5) {
return;
}
const brewConnectionPort = env.PUBLIC_BREW_CONN_PORT || 'tcp:36588';
// add retry mechanism
const stream = await connectWithRetry(
async () => inst.transport.connect(env.PUBLIC_BREW_CONN_PORT),
`connect to Android server port ${env.PUBLIC_BREW_CONN_PORT}`,
async () => inst.transport.connect(brewConnectionPort),
`connect to Android server port ${brewConnectionPort}`,
3,
500
);
@ -316,22 +526,24 @@ async function connectToAndroidServer(maxRetries = 5) {
if (writer) {
addNotification('INFO:Enable Brewing Mode T on machine');
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
handleAdbPayload(new TextDecoder().decode(value));
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
handleAdbPayload(new TextDecoder().decode(value));
}
} catch (e) {
console.error('read error', e);
if (isRecoverableError(e)) {
void connectToAndroidServer();
}
} finally {
adbWriter.set(null);
addNotification('WARN:Brewing Mode T Offline ...');
}
} catch (e) {
console.error('read error', e);
if (isRecoverableError(e)) {
throw e;
}
throw e;
} finally {
adbWriter.set(null);
addNotification('WARN:Brewing Mode T Offline ...');
}
})();
return;
} else {
addNotification('WARN:Brewing Mode T unavailable');
@ -366,6 +578,94 @@ async function connectToAndroidServer(maxRetries = 5) {
}
}
async function connectToAndroidRecipeMenuServer(notifyFailure = true, retryOnFailure = false) {
if (recipeMenuAndroidServerConnectPromise) return recipeMenuAndroidServerConnectPromise;
recipeMenuAndroidServerConnectPromise = connectToAndroidRecipeMenuServerOnce(
notifyFailure,
retryOnFailure
).finally(() => {
recipeMenuAndroidServerConnectPromise = null;
});
return recipeMenuAndroidServerConnectPromise;
}
async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryOnFailure = false) {
try {
let inst = getAdbInstance();
if (!inst) {
console.warn('recipe menu adb instance not found');
return;
}
const brewConnectionPort = env.PUBLIC_BREW_CONN_PORT || 'tcp:36588';
clearRecipeMenuAndroidServerRetry();
const stream = await inst.transport.connect(brewConnectionPort);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
console.log('checking recipe menu writer ', writer);
adbWriter.set(writer);
if (writer) {
addNotification('INFO:Enable Android recipe menu channel');
} else {
addNotification('WARN:Android recipe menu channel unavailable');
setTimeout(async () => {
console.log('reconnecting android recipe menu server');
await connectToAndroidRecipeMenuServer();
}, 5000);
}
(async () => {
try {
let messageBuffer = '';
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const decoded = decoder.decode(value, { stream: true });
console.log('[ADB Reader] Received raw:', decoded.slice(0, 200));
messageBuffer += decoded;
const messages = messageBuffer.split('\n');
messageBuffer = messages.pop() ?? '';
for (const message of messages) {
const trimmedMessage = message.trim();
if (trimmedMessage) {
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
handleAdbPayload(trimmedMessage);
}
}
}
const remainingMessage = messageBuffer.trim();
if (remainingMessage) {
handleAdbPayload(remainingMessage);
}
} catch (e) {
console.error('recipe menu read error', e);
} finally {
adbWriter.set(null);
addNotification('WARN:Android recipe menu channel offline ...');
if (retryOnFailure) {
scheduleRecipeMenuAndroidServerReconnect();
}
}
})();
} catch (err) {
console.error('Recipe menu connection failed. Suspect java running or not', err);
adbWriter.set(null);
if (notifyFailure) addNotification('ERR:Fail to enable Android recipe menu channel');
if (retryOnFailure) {
scheduleRecipeMenuAndroidServerReconnect();
}
}
}
// logcat stream
// TODO: screen mirror

View file

@ -7,7 +7,6 @@ export async function checkAllowAccess(userDomain: string): Promise<boolean> {
if (snapshot.exists()) {
let domains = snapshot.data();
// console.log(`domains: ${JSON.stringify(domains)}`);
return domains['account_email'].includes(userDomain);
}

View file

@ -18,8 +18,11 @@ async function sendCommand(type: string, params?: string[]) {
let inst = adb.getAdbInstance();
if (inst) {
try {
const commandPath = env.PUBLIC_BREW_CMD_WEB;
if (!commandPath) throw new BrewCommandError('PUBLIC_BREW_CMD_WEB is not configured');
let cmd = type + ' ' + (params?.join(' ') ?? '');
await adb.push(env.PUBLIC_BREW_CMD_WEB, cmd);
await adb.push(commandPath, cmd);
} catch (e) {
throw new BrewCommandError('Command failed', `${e}`);
}
@ -32,9 +35,18 @@ async function sendReset() {
let inst = adb.getAdbInstance();
if (inst) {
try {
await adb.push(env.PUBLIC_BREW_CMD_WEB, '');
await adb.push(env.PUBLIC_BREW_CURRENT_RECIPE, '');
await adb.push(env.PUBLIC_BREW_WEB_STATUS, '');
const commandPath = env.PUBLIC_BREW_CMD_WEB;
const currentRecipePath = env.PUBLIC_BREW_CURRENT_RECIPE;
const statusPath = env.PUBLIC_BREW_WEB_STATUS;
if (!commandPath) throw new BrewCommandError('PUBLIC_BREW_CMD_WEB is not configured');
if (!currentRecipePath)
throw new BrewCommandError('PUBLIC_BREW_CURRENT_RECIPE is not configured');
if (!statusPath) throw new BrewCommandError('PUBLIC_BREW_WEB_STATUS is not configured');
await adb.push(commandPath, '');
await adb.push(currentRecipePath, '');
await adb.push(statusPath, '');
} catch (e) {
throw new BrewCommandError('Reset failed', `${e}`);
}

View file

@ -1,44 +1,75 @@
import { get } from 'svelte/store';
import { updateMachineStatus } from '../stores/machineInfoStore';
import { addNotification } from '../stores/noti';
import {
loadAndroidRecipeExportFromDevice,
saveAndroidRecipeExportPayload
} from '../services/androidRecipeExportService';
import { handleIncomingMessages } from './messageHandler';
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
import { recipeFromMachineQuery } from '../stores/recipeStore';
type AdbPayload = { type: string; payload: any };
async function handleAdbPayload(raw_payload: string) {
console.log('get payload', raw_payload);
console.log('[ADB] Received payload:', raw_payload.slice(0, 300));
try {
const payload: AdbPayload = JSON.parse(raw_payload);
console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
switch (payload.type) {
case 'log':
let log_level = payload.payload['level'] ?? 'INFO';
let log_message = payload.payload['msg'] ?? '';
if (log_message !== '') addNotification(`${log_level}`);
if (log_message !== '') {
console.log('[ADB LOG]', log_level, log_message);
addNotification(`${log_level}:${log_message}`);
}
break;
case 'response':
if (payload.payload instanceof String) {
if (typeof payload.payload === 'string' || payload.payload instanceof String) {
// single message response
let raw_payload = payload.payload.toString();
if (raw_payload.startsWith('save_recipe_machine')) {
if (
raw_payload.startsWith('save_recipe_machine') ||
raw_payload.startsWith('save_recipe_menu_file')
) {
let res = raw_payload.split('/');
let pd = res[1] ?? '';
let action = res[2] ?? '';
let uiAction = res[3] ?? '';
handleIncomingMessages(
JSON.stringify({
type: 'ui_action',
payload: {
action: uiAction,
from: 'brew',
ref: `${pd}.${action}`
}
})
);
console.log('[ADB] Save response parsed:', { pd, action, uiAction, raw_payload });
// Track menu save status
if (raw_payload.startsWith('save_recipe_menu_file') && pd) {
if (action === 'success' || action === 'ok' || uiAction === 'refreshNow') {
setMenuSaved(pd);
addNotification(`INFO:Menu saved: ${pd}`);
} else if (action === 'error' || action === 'fail') {
setMenuSaveError(pd, 'Save failed');
addNotification(`ERR:Failed to save menu: ${pd}`);
} else {
// Assume success if we get a response
setMenuSaved(pd);
addNotification(`INFO:Menu saved: ${pd}`);
}
}
if (raw_payload.startsWith('save_recipe_machine')) {
handleIncomingMessages(
JSON.stringify({
type: 'ui_action',
payload: {
action: uiAction,
from: 'brew',
ref: `${pd}.${action}`
}
})
);
}
} else if (raw_payload.startsWith('state')) {
let res = raw_payload.split('/');
let new_machine_state = res[1] ?? '';
@ -98,6 +129,39 @@ async function handleAdbPayload(raw_payload: string) {
addNotification(`ERR:${payload.payload}`);
// send message to server if needed
break;
case 'recipe-export':
if (payload.payload?.content) {
saveAndroidRecipeExportPayload({
content: payload.payload.content,
exportedAt: payload.payload.exportedAt,
source: payload.payload.source,
fileSizeBytes: payload.payload.fileSizeBytes,
lineCount: payload.payload.lineCount,
message: payload.payload.message
});
addNotification('INFO:Recipe export received from Android');
} else if (payload.payload?.message) {
addNotification(`ERR:${payload.payload.message}`);
}
break;
case 'recipe-export-ready':
if (payload.payload?.message && payload.payload?.fileSizeBytes === 0) {
addNotification(`ERR:${payload.payload.message}`);
break;
}
void loadAndroidRecipeExportFromDevice({
exportedAt: payload.payload?.exportedAt,
source: payload.payload?.source,
fileSizeBytes: payload.payload?.fileSizeBytes,
lineCount: payload.payload?.lineCount,
message: payload.payload?.message
})
.then(() => addNotification('INFO:Recipe export loaded from Android'))
.catch((error: any) =>
addNotification(`ERR:${error?.message ?? 'Unable to load recipe export from Android'}`)
);
break;
default:
}
} catch (error: any) {

View file

@ -15,6 +15,24 @@ import {
toppingGroupFromServerQuery,
toppingListFromServerQuery
} from '../stores/recipeStore';
import {
handleSheetStreamStart,
handleSheetStreamChunk,
handleSheetStreamEnd,
handleSheetStreamError,
handleCatalogsResponse,
handleListMenuResponse,
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
handleRawStreamEnd
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
handleGenLayoutFile,
handleGenLayoutBatchEnd,
handleGenLayoutError
} from '../stores/genLayoutStore';
import { buildOverviewFromServer } from '$lib/data/recipeService';
import { auth } from '../client/firebase';
import { type RecipeVersion } from '$lib/models/recipe_version.model';
@ -208,22 +226,108 @@ const handlers: Record<string, (payload: any) => void> = {
},
stream_patch_update: (p) => {},
notify: (p) => {
let noti_level = p.level ?? 'INFO';
let msg = p.msg ?? `Notify from ${p.from}`;
let target = p.to;
const from = p.from;
const level = p.level ?? 'INFO';
const msg = p.msg;
const target = p.to;
// Handle list-menu response
if (from === 'list-menu') {
const currentUid = auth.currentUser?.uid;
if (target && currentUid && target === currentUid && p.value) {
handleListMenuResponse({ codes: p.value });
}
return;
}
// Handle gen-service responses
if (from === 'gen-service') {
switch (level) {
case 'batch_start':
handleGenLayoutBatchStart({
batch_id: p.batch_id,
total_files: p.total_files,
total_size_bytes: p.total_size_bytes
});
addNotification(`INFO:Gen Layout started (${p.total_files} files)`);
break;
case 'file':
handleGenLayoutFile({
batch_id: p.batch_id,
file_index: p.file_index,
total_files: p.total_files,
file: p.file,
content: p.content,
is_chunked: p.is_chunked,
part_index: p.part_index,
total_parts: p.total_parts,
is_last_part: p.is_last_part
});
break;
case 'batch_end':
handleGenLayoutBatchEnd({
batch_id: p.batch_id,
total_files: p.total_files
});
addNotification('INFO:Gen Layout complete');
break;
case 'ERROR':
handleGenLayoutError(msg);
addNotification(`ERR:Gen Layout error: ${msg}`);
break;
default:
console.log('[GenService] Received:', level, msg);
}
return;
}
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
if (target && currentUid && target === currentUid) {
if (!msg && p.content?.catalogs) {
handleCatalogsResponse(p.content);
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
return;
}
// Handle streaming messages (with msg field)
switch (msg) {
case 'start':
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
break;
case 'chunk':
handleSheetStreamChunk(p);
break;
case 'end':
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
break;
case 'error':
handleSheetStreamError(p);
addNotification(`ERR:Sheet streaming error: ${p.content?.error_detail}`);
break;
default:
// Handle other content notifications from sheet-service
console.log('[Sheet] Received content:', p.content);
}
}
return;
}
// Default notification handling
let from_service = p.from ?? '';
let ref_service = p.ref ?? '';
if (target) {
//
let currentUsername = auth.currentUser?.displayName;
if (currentUsername && currentUsername === target) {
addNotification(`${noti_level}:${msg}`);
addNotification(`${level}:${msg}`);
}
} else {
// broadcast to all
addNotification(`${noti_level}:${msg}`);
addNotification(`${level}:${msg}`);
}
},
ui_action: (p) => {
@ -348,12 +452,33 @@ const handlers: Record<string, (payload: any) => void> = {
socketConnectionOfflineCount.set(0);
socketAlreadySendHeartbeat.set(0);
console.log('heartbeat reset offline count');
},
// Raw stream handlers for sheet data (e.g., price)
raw_stream: (p) => {
// Format: raw_stream with subtype in payload
// Header: { subtype: 'price', request_id, header?, country? }
const subtype = p.subtype;
if (subtype) {
handleRawStreamHeader(subtype, p);
}
},
raw_stream_price: (p) => {
// Header for price stream
handleRawStreamHeader('price', p);
},
raw_stream_chunk_price: (p) => {
// Chunk for price stream
handleRawStreamChunk('price', p);
},
raw_stream_end_price: (p) => {
// End for price stream
handleRawStreamEnd('price', p);
}
};
export function handleIncomingMessages(raw: string) {
const msg: WSMessage = JSON.parse(raw);
// console.log(`${new Date().toLocaleTimeString()}:ws msg`, msg);
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
if (msg == null) {
// error response
addNotification('ERR:No response from server');

View file

@ -18,7 +18,7 @@ function getServiceName(cmdReq: CommandRequest) {
}
// Websocket message wrapper for commands like `sheet`, `command`
export function sendCommandRequest(target: CommandRequest, values: any) {
export function sendCommandRequest(target: CommandRequest, values: any): boolean {
let srv_name = getServiceName(target);
let curr_user = get(auth);
@ -31,7 +31,7 @@ export function sendCommandRequest(target: CommandRequest, values: any) {
};
}
sendMessage({
return sendMessage({
type: target,
payload: {
user_info: user_info ?? {},

View file

@ -0,0 +1,284 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
export const ANDROID_RECIPE_EXPORT_PATH = '/mnt/sdcard/recipe_export_all.tsv';
const ANDROID_RECIPE_EXPORT_CACHE_KEY = 'android_recipe_export_payload_v1';
const ANDROID_RECIPE_EXPORT_DB_NAME = 'android_recipe_export_cache';
const ANDROID_RECIPE_EXPORT_STORE_NAME = 'payloads';
export type AndroidRecipeExportPayload = {
content: string;
exportedAt?: number;
source?: string;
fileSizeBytes?: number;
lineCount?: number;
message?: string;
};
export type AndroidRecipeExportRow = {
lineNumber: number;
cells: string[];
values: Record<string, string>;
};
export type AndroidRecipeExportData = {
headers: string[];
rows: AndroidRecipeExportRow[];
lineCount: number;
};
export const androidRecipeExportPayload = writable<AndroidRecipeExportPayload | null>(null);
let deviceExportLoadPromise: Promise<void> | null = null;
function openAndroidRecipeExportDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(ANDROID_RECIPE_EXPORT_DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function readCachedPayloadFromIndexedDb(): Promise<AndroidRecipeExportPayload | null> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readonly');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
const request = store.get(ANDROID_RECIPE_EXPORT_CACHE_KEY);
request.onsuccess = () => resolve((request.result as AndroidRecipeExportPayload) ?? null);
request.onerror = () => reject(request.error);
transaction.oncomplete = () => db.close();
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
async function writeCachedPayloadToIndexedDb(payload: AndroidRecipeExportPayload): Promise<void> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readwrite');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
store.put(payload, ANDROID_RECIPE_EXPORT_CACHE_KEY);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
async function deleteCachedPayloadFromIndexedDb(): Promise<void> {
const db = await openAndroidRecipeExportDb();
return new Promise((resolve, reject) => {
const transaction = db.transaction(ANDROID_RECIPE_EXPORT_STORE_NAME, 'readwrite');
const store = transaction.objectStore(ANDROID_RECIPE_EXPORT_STORE_NAME);
store.delete(ANDROID_RECIPE_EXPORT_CACHE_KEY);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
}
export async function loadCachedAndroidRecipeExport(): Promise<AndroidRecipeExportPayload | null> {
if (!browser) return null;
try {
const payload = await readCachedPayloadFromIndexedDb();
if (!payload?.content) return null;
androidRecipeExportPayload.set(payload);
return payload;
} catch (error) {
console.error('failed to load cached android recipe export from IndexedDB', error);
}
try {
const cached = localStorage.getItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
if (!cached) return null;
const payload = JSON.parse(cached) as AndroidRecipeExportPayload;
if (!payload?.content) return null;
androidRecipeExportPayload.set(payload);
void writeCachedPayloadToIndexedDb(payload);
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
return payload;
} catch (error) {
console.error('failed to load legacy android recipe export cache', error);
return null;
}
}
export function saveAndroidRecipeExportPayload(payload: AndroidRecipeExportPayload) {
androidRecipeExportPayload.set(payload);
if (!browser) return;
void writeCachedPayloadToIndexedDb(payload).catch((error) => {
console.error('failed to cache android recipe export', error);
});
}
export function clearCachedAndroidRecipeExport() {
androidRecipeExportPayload.set(null);
if (!browser) return;
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
void deleteCachedPayloadFromIndexedDb().catch((error) => {
console.error('failed to clear android recipe export cache', error);
});
}
function normalizeHeader(value: string, index: number): string {
const header = value.trim().replace(/^\uFEFF/, '');
return header || `Column ${index + 1}`;
}
function splitTsvLine(line: string): string[] {
return line.split('\t').map((cell) => cell.trim());
}
function collectNonEmptyLines(raw: string, maxLines: number): string[] {
const lines: string[] = [];
let lineStart = 0;
for (let index = 0; index <= raw.length; index += 1) {
const isEnd = index === raw.length;
const char = raw[index];
if (!isEnd && char !== '\n') continue;
const lineEnd = index > lineStart && raw[index - 1] === '\r' ? index - 1 : index;
const line = raw.slice(lineStart, lineEnd);
if (line.trim().length > 0) {
lines.push(line);
if (lines.length >= maxLines) break;
}
lineStart = index + 1;
}
return lines;
}
function buildUniqueHeaders(rawHeaders: string[], maxColumns: number): string[] {
const headers = [...rawHeaders];
for (let i = headers.length; i < maxColumns; i += 1) {
headers.push(`Column ${i + 1}`);
}
const seen = new Map<string, number>();
return headers.map((header, index) => {
const normalized = normalizeHeader(header, index);
const count = seen.get(normalized) ?? 0;
seen.set(normalized, count + 1);
return count === 0 ? normalized : `${normalized} ${count + 1}`;
});
}
export function parseAndroidRecipeExport(
raw: string,
maxRows = Number.POSITIVE_INFINITY
): AndroidRecipeExportData {
const maxLines = Number.isFinite(maxRows) ? Math.max(1, maxRows + 1) : Number.MAX_SAFE_INTEGER;
const lines = collectNonEmptyLines(raw, maxLines);
if (lines.length === 0) {
return {
headers: [],
rows: [],
lineCount: 0
};
}
const parsedLines = lines.map(splitTsvLine);
const maxColumns = Math.max(...parsedLines.map((line) => line.length));
const headers = buildUniqueHeaders(parsedLines[0], maxColumns);
const rows = parsedLines.slice(1).map((cells, index) => {
const paddedCells = [...cells];
for (let cellIndex = paddedCells.length; cellIndex < headers.length; cellIndex += 1) {
paddedCells.push('');
}
const values = Object.fromEntries(
headers.map((header, cellIndex) => [header, paddedCells[cellIndex] ?? ''])
);
return {
lineNumber: index + 2,
cells: paddedCells,
values
};
});
return {
headers,
rows,
lineCount: lines.length
};
}
export async function pullAndroidRecipeExport(timeoutMs = 15000): Promise<string> {
const adb = await import('$lib/core/adb/adb');
const instance = adb.getAdbInstance();
if (!instance) {
throw new Error('ADB device is not connected');
}
const content = await adb.pull(ANDROID_RECIPE_EXPORT_PATH, timeoutMs);
if (content === undefined) {
throw new Error(`Unable to pull ${ANDROID_RECIPE_EXPORT_PATH}`);
}
return content;
}
export async function loadAndroidRecipeExportFromDevice(
meta: Partial<AndroidRecipeExportPayload> = {}
): Promise<void> {
if (deviceExportLoadPromise) return deviceExportLoadPromise;
deviceExportLoadPromise = (async () => {
const content = await pullAndroidRecipeExport(30000);
saveAndroidRecipeExportPayload({
content,
exportedAt: meta.exportedAt ?? Date.now(),
source: meta.source ?? ANDROID_RECIPE_EXPORT_PATH,
fileSizeBytes: meta.fileSizeBytes,
lineCount: meta.lineCount,
message: meta.message
});
})().finally(() => {
deviceExportLoadPromise = null;
});
return deviceExportLoadPromise;
}

View file

@ -0,0 +1,251 @@
import { sendCommandRequest, sendMessage } from '../handlers/ws_messageSender';
import { get } from 'svelte/store';
import { auth } from '../stores/auth';
import {
productCodesLoading,
hasSheetPriceBeenSent,
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
setPendingProductCodesCountry
} from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
export function requestCatalogs(country: string): boolean {
return sendCommandRequest('sheet', {
country: country,
param: 'catalogs'
});
}
export function enterRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'enter'
});
}
export function sendHeartbeat(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'heartbeat'
});
}
export function exitRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'exit'
});
}
export function requestCatalogMenu(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'catalog/menu'
});
}
export function updateMenu(country: string, catalog: string, content: any[]): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'update/menu'
});
}
export function addMenu(country: string, catalog: string, content: any[]): boolean {
console.log('[sheetService] Adding menu:', { country, catalog, content });
const sent = sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'add/menu'
});
console.log('[sheetService] Add menu sent:', sent);
return sent;
}
export function deleteMenu(country: string, catalog: string, targetIds: number[]): boolean {
const content = targetIds.map((id) => ({ target_id: id }));
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
param: 'delete/menu'
});
}
export function swapMenu(
country: string,
catalog: string,
swaps: { source_id: number; target_id: number }[]
): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: swaps,
param: 'swap/menu'
});
}
export function requestListMenu(country: string, boxid?: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
if (curr_user) {
user_info = {
displayName: curr_user.displayName,
email: curr_user.email,
uid: curr_user.uid
};
}
productCodesLoading.set(true);
setPendingProductCodesCountry(country);
console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
return sendMessage({
type: 'list_menu',
payload: {
user_info,
country,
boxid: boxid || undefined
}
});
}
export function requestGenLayout(country: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
if (curr_user) {
user_info = {
displayName: curr_user.displayName,
email: curr_user.email,
uid: curr_user.uid
};
}
setGenLayoutGenerating();
console.log('[sheetService] Sending gen-layout request for country:', country);
return sendMessage({
type: 'command',
payload: {
user_info,
srv_name: 'gen-service',
values: {
file_layout: 'sheet',
file_desc: 'sheet',
country: country,
param: 'new-inter-v3-multi-promotion-other_catalog-supra_app'
}
}
});
}
/**
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
export function requestSheetPrice(country: string, productCodes: string[]): boolean {
// Check if already sent
if (hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
if (!productCodes || productCodes.length === 0) {
console.warn('[sheetService] No product codes to request price for');
return false;
}
// Generate request_id (UUID v4)
const request_id = crypto.randomUUID();
// Store request_id and country in streamingRawData for tracking
streamingRawData.update((data) => ({
...data,
price: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
sheetPriceLoading.set(true);
// Convert to array of objects (backend expects objects, not strings)
const content = productCodes.map((code) => ({ product_code: code }));
console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id);
const sent = sendCommandRequest('sheet', {
country: country,
content: content,
param: 'price',
stream: true,
request_id
});
if (sent) {
markSheetPriceAsSent('price');
} else {
sheetPriceLoading.set(false);
}
return sent;
}
/**
* Update price data in sheet
* content: [{ row_index: number, cells: [{ value: string, coord: { row: number, col: number } }] }]
*/
export function updateSheetPrice(
country: string,
content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[]
): boolean {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to update');
return false;
}
console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length);
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/price'
});
}
/**
* Add new price rows to sheet (for product codes that don't exist in price sheet)
* content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }]
*/
export function addSheetPrice(
country: string,
content: { cells: string[] }[]
): boolean {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to add');
return false;
}
console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content);
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price'
});
}

View file

@ -7,16 +7,27 @@ async function sendToAndroid(message: any) {
let writer: any = get(adbWriter);
console.log('writer', writer);
if (!writer) {
// addNotification('ERR:No active connection');
return;
addNotification('ERR:No active Android connection');
return false;
}
try {
const encoder = new TextEncoder();
// console.log(writer);
await writer.write(encoder.encode(JSON.stringify(message) + '\n'));
console.log('sent! ', JSON.stringify(message).length);
const serializedMessage = JSON.stringify(message);
await writer.write(encoder.encode(serializedMessage + '\n'));
console.log('[ADB] sent', {
type: message?.type,
bytes: serializedMessage.length,
productCode: message?.payload?.data?.productCode,
batchCount: Array.isArray(message?.payload?.data) ? message.payload.data.length : undefined,
batchProductCodes: Array.isArray(message?.payload?.data)
? message.payload.data.map((menu: any) => menu?.productCode)
: undefined
});
return true;
} catch (error) {
console.error('write failed', error);
addNotification('ERR:Failed to send message to Android');
return false;
}
}

View file

@ -0,0 +1,249 @@
import { writable, get } from 'svelte/store';
export interface GenLayoutFile {
file: string;
content: string;
file_index: number;
}
export interface GenLayoutBatch {
batch_id: string;
total_files: number;
total_size_bytes: number;
status: 'idle' | 'generating' | 'receiving' | 'complete' | 'error';
files: GenLayoutFile[];
error?: string;
}
// Track chunked file parts: Map<file_index, Map<part_index, content>>
interface ChunkedFileTracker {
file: string;
file_index: number;
total_parts: number;
parts: Map<number, string>;
}
const initialState: GenLayoutBatch = {
batch_id: '',
total_files: 0,
total_size_bytes: 0,
status: 'idle',
files: []
};
export const genLayoutBatch = writable<GenLayoutBatch>(initialState);
// Track chunked files being assembled
const chunkedFiles = new Map<number, ChunkedFileTracker>();
// Callbacks for when batch completes
let onBatchCompleteCallback: ((files: GenLayoutFile[]) => void) | null = null;
export function setOnBatchCompleteCallback(cb: (files: GenLayoutFile[]) => void) {
onBatchCompleteCallback = cb;
}
export function clearOnBatchCompleteCallback() {
onBatchCompleteCallback = null;
}
export function handleGenLayoutBatchStart(payload: {
batch_id: string;
total_files: number;
total_size_bytes: number;
}) {
genLayoutBatch.set({
batch_id: payload.batch_id,
total_files: payload.total_files,
total_size_bytes: payload.total_size_bytes,
status: 'receiving',
files: []
});
console.log('[GenLayout] Batch started:', payload.batch_id, 'total files:', payload.total_files);
}
export function handleGenLayoutFile(payload: {
batch_id: string;
file_index: number;
total_files: number;
file: string;
content: string;
is_chunked?: boolean;
part_index?: number;
total_parts?: number;
is_last_part?: boolean;
}) {
const batch = get(genLayoutBatch);
if (batch.batch_id !== payload.batch_id) return;
if (payload.is_chunked) {
const fileIndex = payload.file_index;
const partIndex = payload.part_index ?? 0;
const totalParts = payload.total_parts ?? 1;
// Get or create tracker for this file
let tracker = chunkedFiles.get(fileIndex);
if (!tracker) {
tracker = {
file: payload.file,
file_index: fileIndex,
total_parts: totalParts,
parts: new Map()
};
chunkedFiles.set(fileIndex, tracker);
}
// Store this chunk
tracker.parts.set(partIndex, payload.content);
console.log(
'[GenLayout] Received chunk:',
partIndex + 1,
'/',
totalParts,
'for file',
fileIndex + 1,
'/',
payload.total_files,
payload.file
);
// Check if all parts received
if (tracker.parts.size === totalParts) {
// Assemble the complete file content
const sortedParts: string[] = [];
for (let i = 0; i < totalParts; i++) {
sortedParts.push(tracker.parts.get(i) ?? '');
}
const completeContent = sortedParts.join('');
// Add to files
addFileToStore(payload.batch_id, {
file: payload.file,
content: completeContent,
file_index: fileIndex
}, payload.total_files);
// Clean up tracker
chunkedFiles.delete(fileIndex);
console.log(
'[GenLayout] Assembled chunked file:',
fileIndex + 1,
'/',
payload.total_files,
payload.file
);
}
} else {
// Handle non-chunked file
addFileToStore(payload.batch_id, {
file: payload.file,
content: payload.content,
file_index: payload.file_index
}, payload.total_files);
console.log(
'[GenLayout] Received file:',
payload.file_index + 1,
'/',
payload.total_files,
payload.file
);
}
}
function addFileToStore(batchId: string, file: GenLayoutFile, totalFiles: number) {
genLayoutBatch.update((batch) => {
if (batch.batch_id !== batchId) return batch;
const existingIndex = batch.files.findIndex((f) => f.file_index === file.file_index);
const newFiles =
existingIndex >= 0
? batch.files.map((f, index) => (index === existingIndex ? file : f))
: [...batch.files, file];
const sortedFiles = newFiles.sort((a, b) => a.file_index - b.file_index);
return {
...batch,
total_files: totalFiles || batch.total_files,
files: sortedFiles
};
});
}
export function handleGenLayoutBatchEnd(payload: { batch_id: string; total_files: number }) {
const batch = get(genLayoutBatch);
if (batch.batch_id !== payload.batch_id) return;
const expectedTotal = payload.total_files || batch.total_files;
const sortedFiles = [...batch.files].sort((a, b) => a.file_index - b.file_index);
const missingIndexes = getMissingFileIndexes(sortedFiles, expectedTotal);
if (missingIndexes.length > 0) {
// const error = `Gen Layout incomplete: received ${sortedFiles.length}/${expectedTotal} files. Missing file index: ${missingIndexes.join(', ')}`;
const error = `ไฟล์ไม่ครับ ทั้งหมด: ${sortedFiles.length}/${expectedTotal}`
genLayoutBatch.update((b) => ({
...b,
total_files: expectedTotal,
status: 'error',
files: sortedFiles,
error
}));
console.warn('[GenLayout] Batch incomplete:', error, sortedFiles);
return;
}
genLayoutBatch.update((b) => ({
...b,
total_files: expectedTotal,
status: 'complete',
files: sortedFiles
}));
console.log('[GenLayout] Batch complete, received', sortedFiles.length, 'files');
if (onBatchCompleteCallback) {
onBatchCompleteCallback(sortedFiles);
}
}
function getMissingFileIndexes(files: GenLayoutFile[], totalFiles: number) {
if (totalFiles <= 0) return [];
const receivedIndexes = new Set(files.map((file) => file.file_index));
const indexes = [...receivedIndexes];
const startsAtOne = indexes.length > 0 && Math.min(...indexes) >= 1;
const firstIndex = startsAtOne ? 1 : 0;
const lastIndex = startsAtOne ? totalFiles : totalFiles - 1;
const missingIndexes: number[] = [];
for (let index = firstIndex; index <= lastIndex; index += 1) {
if (!receivedIndexes.has(index)) {
missingIndexes.push(index);
}
}
return missingIndexes;
}
export function handleGenLayoutError(error: string) {
genLayoutBatch.update((batch) => ({
...batch,
status: 'error',
error
}));
}
export function resetGenLayoutBatch() {
genLayoutBatch.set(initialState);
chunkedFiles.clear();
}
export function setGenLayoutGenerating() {
genLayoutBatch.update((batch) => ({
...batch,
status: 'generating'
}));
}

View file

@ -0,0 +1,85 @@
import { writable, get } from 'svelte/store';
export type MenuSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
export interface MenuSaveState {
productCode: string;
status: MenuSaveStatus;
error?: string;
savedAt?: number;
}
// Store for tracking menu save status
export const menuSaveStates = writable<Map<string, MenuSaveState>>(new Map());
// Callback to be called when a menu is saved successfully
let onMenuSavedCallback: ((productCode: string) => void) | null = null;
export function setOnMenuSavedCallback(callback: (productCode: string) => void) {
onMenuSavedCallback = callback;
}
export function clearOnMenuSavedCallback() {
onMenuSavedCallback = null;
}
export function setMenuSaving(productCode: string) {
menuSaveStates.update((states) => {
const newStates = new Map(states);
newStates.set(productCode, {
productCode,
status: 'saving'
});
return newStates;
});
}
export function setMenuSaved(productCode: string) {
menuSaveStates.update((states) => {
const newStates = new Map(states);
newStates.set(productCode, {
productCode,
status: 'saved',
savedAt: Date.now()
});
return newStates;
});
// Call the callback if registered
if (onMenuSavedCallback) {
onMenuSavedCallback(productCode);
}
}
export function setMenuSaveError(productCode: string, error: string) {
menuSaveStates.update((states) => {
const newStates = new Map(states);
newStates.set(productCode, {
productCode,
status: 'error',
error
});
return newStates;
});
}
export function clearMenuSaveState(productCode: string) {
menuSaveStates.update((states) => {
const newStates = new Map(states);
newStates.delete(productCode);
return newStates;
});
}
export function getMenuSaveStatus(productCode: string): MenuSaveStatus {
const states = get(menuSaveStates);
return states.get(productCode)?.status ?? 'idle';
}
export function isMenuSaving(productCode: string): boolean {
return getMenuSaveStatus(productCode) === 'saving';
}
export function isMenuSaved(productCode: string): boolean {
return getMenuSaveStatus(productCode) === 'saved';
}

View file

@ -0,0 +1,805 @@
import { writable, get } from 'svelte/store';
// Catalog types
export interface Catalog {
catalog: string;
row_index: number;
status: 'free' | 'locked';
locked_by: string | null;
}
export interface CatalogsResponse {
status: string;
country: string;
catalogs: Catalog[];
}
export const sheetCatalogs = writable<Catalog[]>([]);
export const sheetCatalogsLoading = writable<boolean>(false);
export const countryPrimaryLanguageMap: Record<string, string> = {
THAI: 'Thai',
tha: 'Thai',
cocktail_tha: 'Thai',
counter01: 'Thai',
MYS: 'Malaysia',
mys: 'Malaysia',
IDR: 'Indonesian',
idr: 'Indonesian',
AUS: 'English',
aus: 'English',
USA_PEPSI: 'English',
usa_pepsi: 'English',
SG: 'English',
SGP: 'English',
sgp: 'English',
UAE_DUBAI: 'Arabic',
uae_dubai: 'Arabic',
dubai: 'Arabic',
HKG: 'Cantonese',
hkg: 'Cantonese',
GBR: 'English',
gbr: 'English',
LTU: 'Lithuanian',
ltu: 'Lithuanian',
ROU: 'Romanian',
rou: 'Romanian',
LVA: 'Latvian',
lva: 'Latvian',
EST: 'Estonian',
est: 'Estonian'
};
export function getCountryPrimaryLanguage(countryCode: string): string {
return (
countryPrimaryLanguageMap[countryCode] ??
countryPrimaryLanguageMap[countryCode.toUpperCase()] ??
'Unknown'
);
}
// Sheet column configuration by country for new_layout_v2
// Maps language keys to column indices and product code columns
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
language: Record<string, number>;
productCode: { hot: number; cold: number; blend: number };
primaryLanguage: string;
}> = {
tha: {
language: { en: 3, th: 4, zh: 5, my: 8 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'th'
},
aus: {
language: { en: 3, th: 4 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
gbr: {
language: { en: 3 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
hkg: {
language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'zh_hant'
},
ltu: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'lt'
},
rou: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'ro'
},
lva: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
est: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
mys: {
language: { en: 3, th: 4, ms: 7 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'ms'
},
sgp: {
language: { en: 3, th: 4 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
uae_dubai: {
language: { en: 3, ar: 4 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'ar'
},
dubai: {
language: { en: 3, ar: 4 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'ar'
},
default: {
language: { en: 3, th: 4 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
}
};
export function getSheetColumnConfig(countryCode: string) {
return SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()]
|| SHEET_COLUMN_CONFIG_BY_COUNTRY.default;
}
export function handleCatalogsResponse(content: CatalogsResponse) {
if (content && content.catalogs) {
sheetCatalogs.set(content.catalogs);
}
sheetCatalogsLoading.set(false);
}
export interface SheetStreamMeta {
batch_id: string;
total_chunks: number;
total_items: number;
current_chunk: number;
status: 'idle' | 'streaming' | 'complete' | 'error';
error?: string;
}
export interface SheetMenuItem {
new_layout_v2: {
row_index: number;
cells: { value: string; coord: { row: number; col: number } }[];
}[];
name_desc_v2: {
key: string;
row_index: number;
cells: { value: string; coord: { row: number; col: number } }[];
}[];
}
// Store for streaming metadata
export const sheetStreamMeta = writable<SheetStreamMeta | null>(null);
// Store for accumulated sheet data
export const sheetData = writable<SheetMenuItem[]>([]);
// Store for loading state
export const sheetLoading = writable<boolean>(false);
// Store for error state
export const sheetError = writable<string | null>(null);
// Handler functions for sheet-service streaming
export function handleSheetStreamStart(payload: {
batch_id: string;
total_chunks: number;
total_items: number;
content: { message: string };
}) {
sheetStreamMeta.set({
batch_id: payload.batch_id,
total_chunks: payload.total_chunks,
total_items: payload.total_items,
current_chunk: 0,
status: 'streaming'
});
sheetData.set([]);
sheetLoading.set(true);
sheetError.set(null);
}
export function handleSheetStreamChunk(payload: {
batch_id: string;
current_chunk: number;
total_chunks: number;
total_items: number;
content: SheetMenuItem[];
}) {
const meta = get(sheetStreamMeta);
// Verify batch_id matches
if (meta && meta.batch_id === payload.batch_id) {
// Append new data
sheetData.update((current) => [...current, ...payload.content]);
// Update progress
sheetStreamMeta.set({
...meta,
current_chunk: payload.current_chunk,
total_chunks: payload.total_chunks,
total_items: payload.total_items
});
}
}
export function handleSheetStreamEnd(payload: {
batch_id: string;
total_chunks: number;
total_items: number;
content: { message: string };
}) {
const meta = get(sheetStreamMeta);
if (meta && meta.batch_id === payload.batch_id) {
sheetStreamMeta.set({
...meta,
status: 'complete',
current_chunk: payload.total_chunks
});
sheetLoading.set(false);
}
}
export function handleSheetStreamError(payload: {
batch_id: string;
content: { error_detail: string };
}) {
const meta = get(sheetStreamMeta);
if (meta && meta.batch_id === payload.batch_id) {
sheetStreamMeta.set({
...meta,
status: 'error',
error: payload.content.error_detail
});
sheetError.set(payload.content.error_detail);
sheetLoading.set(false);
}
}
// Reset all sheet stores
export function resetSheetStore() {
sheetStreamMeta.set(null);
sheetData.set([]);
sheetLoading.set(false);
sheetError.set(null);
}
// Store for existing product codes (for duplicate checking)
export const existingProductCodes = writable<Set<string>>(new Set());
export const productCodesLoading = writable<boolean>(false);
// ─────────────────────────────────────────
// Sheet Price Streaming
// ─────────────────────────────────────────
export interface GristCell {
coord: {
col: number;
row: number;
};
value: string;
}
export interface SheetPriceItem {
product_code: string;
cells: GristCell[];
}
// Price sheet header name mappings by country
// Maps our field names to the actual header names in the sheet
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
cash_price: string[]; // Possible header names for cash price
non_cash_price: string[]; // Possible header names for non-cash price
}> = {
tha: {
cash_price: ['Price'],
non_cash_price: ['MainPrice']
},
mys: {
cash_price: ['F', 'Price'],
non_cash_price: ['MainPrice']
},
aus: {
cash_price: ['AUD', 'Price'],
non_cash_price: ['MainPrice']
},
sgp: {
cash_price: ['SGD', 'Price'],
non_cash_price: ['MainPrice']
},
hkg: {
cash_price: ['HK Final Px', 'Price'],
non_cash_price: ['MainPrice']
},
gbr: {
cash_price: ['GBR', 'Price'],
non_cash_price: ['MainPrice']
},
uae_dubai: {
cash_price: ['AED', 'Price'],
non_cash_price: ['MainPrice']
},
dubai: {
cash_price: ['AED', 'Price'],
non_cash_price: ['MainPrice']
},
ltu: {
cash_price: ['Price in Euro', 'Price'],
non_cash_price: ['MainPrice']
},
rou: {
cash_price: ['Price From LTU (EUR)', 'Price in RON'],
non_cash_price: ['MainPrice']
},
lva: {
cash_price: ['Price in Euro', 'Price'],
non_cash_price: ['MainPrice']
},
est: {
cash_price: ['Price in Euro', 'Price'],
non_cash_price: ['MainPrice']
},
// Default fallback for other countries
default: {
cash_price: ['Price', 'Price in Euro', 'CashPrice', 'AUD', 'SGD', 'GBR', 'AED', 'F'],
non_cash_price: ['MainPrice', 'NonCashPrice']
}
};
// Find column index from header array by matching header names
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
for (const name of possibleNames) {
const idx = headerArray.findIndex(h => h.toLowerCase() === name.toLowerCase());
if (idx !== -1) {
// Return col index (header index + 1 because cells start from col 1)
return idx + 1;
}
}
return -1;
}
// Store: lastRequestSheetPrice[country][product_code] = cells (first row only, for display)
export const lastRequestSheetPrice = writable<Record<string, Record<string, GristCell[]>>>({});
// Store: sheetPriceHeader[country] = header array
export const sheetPriceHeader = writable<Record<string, string[]>>({});
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
export const sheetPriceAllRows = writable<Record<string, Record<string, { row: number; cells: GristCell[] }[]>>>({});
// Helper function to get price value from cells using dynamic header lookup
export function getPriceFromCells(
country: string,
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
console.warn(`[getPriceFromCells] No header found for country: ${country}`);
return null;
}
// Get possible header names for this country
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
// Find the column index for this price type
const colIdx = findHeaderIndex(headers, possibleNames);
//console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
if (colIdx < 0) {
console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
return null;
}
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
//console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
return priceCell?.value ?? null;
}
// Store for tracking streaming state
export const sheetPriceStreamMeta = writable<{
request_id: string;
country: string;
status: 'idle' | 'streaming' | 'complete' | 'error';
error?: string;
} | null>(null);
export const sheetPriceLoading = writable<boolean>(false);
// Track sent request types (can only send once per type)
export const sheetPriceSentTypes = writable<Set<string>>(new Set());
// Raw streaming data accumulator
export const streamingRawData = writable<
Record<
string,
{
request_id: string;
header?: string[];
country?: string;
chunks: any[];
rawParts: string[]; // For accumulating raw JSON string parts
}
>
>({});
// Handler: raw_stream header (e.g., raw_stream_price)
export function handleRawStreamHeader(subtype: string, payload: any) {
console.log(`[RawStream] Header for ${subtype}:`, payload);
// Get existing stream data to preserve country from request
const currentData = get(streamingRawData);
const existingData = currentData[subtype];
streamingRawData.update((data) => ({
...data,
[subtype]: {
request_id: payload.request_id,
header: payload.header || payload.headers,
country: payload.country || existingData?.country || '',
chunks: [],
rawParts: [] // Initialize for accumulating raw JSON string parts
}
}));
if (subtype === 'price') {
sheetPriceStreamMeta.set({
request_id: payload.request_id,
country: payload.country || existingData?.country || '',
status: 'streaming'
});
sheetPriceLoading.set(true);
}
}
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
export function handleRawStreamChunk(subtype: string, payload: any) {
console.log(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length);
const currentData = get(streamingRawData);
const streamData = currentData[subtype];
if (!streamData || streamData.request_id !== payload.request_id) {
console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
return;
}
// Check if data is in 'raw' field as JSON string (chunked)
if (payload.raw && typeof payload.raw === 'string') {
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
streamingRawData.update((data) => ({
...data,
[subtype]: {
...streamData,
country: payload.country || streamData.country,
rawParts: [...(streamData.rawParts || []), payload.raw]
}
}));
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
return;
}
// Handle non-chunked payload (already parsed content)
const content = payload.content || payload.data || payload.rows || [];
const contentArray = Array.isArray(content) ? content : [content];
streamingRawData.update((data) => ({
...data,
[subtype]: {
...streamData,
country: payload.country || streamData.country,
chunks: [...streamData.chunks, ...contentArray]
}
}));
console.log(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`);
}
// Handler: raw_stream end (e.g., raw_stream_end_price)
export function handleRawStreamEnd(subtype: string, payload: any) {
console.log(`[RawStream] End payload for ${subtype}:`, payload);
const currentData = get(streamingRawData);
const streamData = currentData[subtype];
if (!streamData || streamData.request_id !== payload.request_id) {
console.warn(`[RawStream] End received for unknown stream: ${subtype}`);
return;
}
// Get country from stored stream data or payload
const country = streamData.country || payload.country || '';
// If we have accumulated raw parts, join and parse them now
let chunks = streamData.chunks || [];
if (streamData.rawParts && streamData.rawParts.length > 0) {
const fullRawJson = streamData.rawParts.join('');
console.log(
`[RawStream] Joining ${streamData.rawParts.length} raw parts, total length: ${fullRawJson.length}`
);
try {
const parsed = JSON.parse(fullRawJson);
console.log(`[RawStream] Parsed combined raw data, keys:`, Object.keys(parsed));
// Extract content from nested structure: { payload: { content: [...] } }
const content = parsed?.payload?.content || parsed?.content || parsed || [];
chunks = Array.isArray(content) ? content : [content];
// Log first item to see actual structure
if (chunks.length > 0) {
console.log(`[RawStream] First content item:`, JSON.stringify(chunks[0]).substring(0, 200));
}
} catch (e) {
console.error(`[RawStream] Failed to parse combined raw JSON:`, e);
}
}
console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
if (subtype === 'price') {
processSheetPriceData(country, streamData.header || [], chunks);
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
sheetPriceLoading.set(false);
}
// Clear the streaming data
streamingRawData.update((data) => {
const newData = { ...data };
delete newData[subtype];
return newData;
});
}
// Process and store sheet price data
function processSheetPriceData(country: string, header: string[], chunks: any[]) {
console.log(`[SheetPrice] Processing data for ${country}:`, {
header,
chunksCount: chunks.length
});
console.log(`[SheetPrice] Sample chunk:`, chunks[0]);
// Try to extract header from first chunk item if not provided separately
// Backend sends header embedded in each item: { header: [...], key: "...", payload: [...] }
let effectiveHeader = header;
if ((!effectiveHeader || effectiveHeader.length === 0) && chunks.length > 0) {
const firstChunk = chunks[0];
if (firstChunk?.header && Array.isArray(firstChunk.header)) {
effectiveHeader = firstChunk.header;
console.log(`[SheetPrice] Extracted header from first chunk:`, effectiveHeader);
}
}
// Save header for dynamic column lookup later
if (effectiveHeader && effectiveHeader.length > 0) {
sheetPriceHeader.update((data) => ({
...data,
[country]: effectiveHeader
}));
console.log(`[SheetPrice] Saved header for ${country}:`, effectiveHeader);
}
// Find column indices dynamically from header
// product_code header is typically "ProductCode" or similar
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
const priceByProductCode: Record<string, GristCell[]> = {};
// Track ALL rows per product code (for duplicates)
const allRowsByProductCode: Record<string, { row: number; cells: GristCell[] }[]> = {};
for (const row of chunks) {
if (!row) {
console.log(`[SheetPrice] Row is null/undefined`);
continue;
}
// Support new structure: {key, payload} where key=product_code
// payload can be: [{cells: [...], row_index: ...}, ...] - multiple entries for duplicates
if (row.key !== undefined) {
const productCode = row.key;
// Handle payload structure - iterate ALL entries in payload for duplicates
if (Array.isArray(row.payload) && row.payload.length > 0) {
// Check if payload[0] has cells property (nested structure with row_index)
if (row.payload[0]?.cells) {
// payload: [{cells: [...], row_index: ...}, {cells: [...], row_index: ...}, ...]
// Store first one for backward compatibility
priceByProductCode[productCode] = row.payload[0].cells;
// Store ALL rows for duplicate handling
if (!allRowsByProductCode[productCode]) {
allRowsByProductCode[productCode] = [];
}
for (const entry of row.payload) {
if (entry.cells && entry.row_index !== undefined) {
allRowsByProductCode[productCode].push({
row: entry.row_index,
cells: entry.cells
});
}
}
} else if (row.payload[0]?.coord) {
// payload: [{coord: {...}, value: ...}, ...] - flat cells array
priceByProductCode[productCode] = row.payload;
// Extract row from first cell's coord
const rowNum = row.payload[0]?.coord?.row;
if (rowNum !== undefined) {
allRowsByProductCode[productCode] = [{ row: rowNum, cells: row.payload }];
}
}
}
continue;
}
// Fallback: Check if row has cells or if row itself is the cells array
let cells: GristCell[] = row.cells || row;
if (!Array.isArray(cells)) {
console.log(`[SheetPrice] Unknown row structure:`, row);
continue;
}
// Find product_code from cells by column index
if (productCodeIdx >= 0) {
const productCodeCell = cells.find((c: GristCell) => c.coord?.col === productCodeIdx);
const productCode = productCodeCell?.value;
if (productCode) {
priceByProductCode[productCode] = cells;
// Extract row from first cell's coord
const rowNum = cells[0]?.coord?.row;
if (rowNum !== undefined) {
if (!allRowsByProductCode[productCode]) {
allRowsByProductCode[productCode] = [];
}
allRowsByProductCode[productCode].push({ row: rowNum, cells });
}
}
}
}
lastRequestSheetPrice.update((data) => ({
...data,
[country]: {
...(data[country] || {}),
...priceByProductCode
}
}));
// Update sheetPriceAllRows for duplicate handling
sheetPriceAllRows.update((data) => ({
...data,
[country]: {
...(data[country] || {}),
...allRowsByProductCode
}
}));
console.log(
`[SheetPrice] Processed ${Object.keys(priceByProductCode).length} prices for ${country}`
);
console.log(`[SheetPrice] Sample product codes:`, Object.keys(priceByProductCode).slice(0, 5));
// Log duplicates info
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
if (duplicates.length > 0) {
console.log(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3));
}
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
const sampleKey = Object.keys(priceByProductCode)[0];
console.log(`[SheetPrice] Sample cells for ${sampleKey}:`, priceByProductCode[sampleKey]);
}
}
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);
sheetPriceLoading.set(false);
streamingRawData.set({});
}
// Check if a request type has already been sent
export function hasSheetPriceBeenSent(type: string): boolean {
return get(sheetPriceSentTypes).has(type);
}
// Mark a request type as sent
export function markSheetPriceAsSent(type: string) {
sheetPriceSentTypes.update((types) => {
const newTypes = new Set(types);
newTypes.add(type);
return newTypes;
});
}
// Clear sent types (for reset)
export function clearSheetPriceSentTypes() {
sheetPriceSentTypes.set(new Set());
}
/**
* Add a newly generated product code to the store (local tracking before server sync)
*/
export function addGeneratedProductCode(code: string) {
existingProductCodes.update((codes) => {
const newSet = new Set(codes);
newSet.add(code);
return newSet;
});
console.log('[sheetStore] Added generated code:', code);
}
const PRODUCT_CODES_STORAGE_KEY = 'supra_product_codes';
// Track current/pending country for product codes
let currentProductCodesCountry = '';
let pendingProductCodesCountry = '';
// Set pending country when making a request
export function setPendingProductCodesCountry(country: string) {
pendingProductCodesCountry = country;
console.log('[sheetStore] Pending product codes country:', country);
}
// Load product codes from localStorage for specific country
export function loadProductCodesFromCache(country?: string): boolean {
try {
const cached = localStorage.getItem(PRODUCT_CODES_STORAGE_KEY);
if (cached) {
const data = JSON.parse(cached);
// Only load if country matches (or no country filter specified)
if (data.codes && Array.isArray(data.codes)) {
if (country && data.country && data.country !== country) {
console.log('[sheetStore] Cache is for different country:', data.country, '!= requested:', country);
// Clear the store for different country
existingProductCodes.set(new Set());
return false;
}
existingProductCodes.set(new Set(data.codes));
currentProductCodesCountry = data.country || '';
console.log('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown');
return true;
}
}
} catch (e) {
console.warn('[sheetStore] Failed to load from cache:', e);
}
// Clear store if no valid cache
existingProductCodes.set(new Set());
return false;
}
// Clear product codes (call when switching countries)
export function clearProductCodes() {
existingProductCodes.set(new Set());
currentProductCodesCountry = '';
console.log('[sheetStore] Cleared product codes');
}
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
// Use pending country if not in payload
const country = payload.country || pendingProductCodesCountry;
console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
if (payload && payload.codes) {
existingProductCodes.set(new Set(payload.codes));
currentProductCodesCountry = country;
// Save to localStorage with country
try {
localStorage.setItem(
PRODUCT_CODES_STORAGE_KEY,
JSON.stringify({
codes: payload.codes,
country: country,
timestamp: Date.now()
})
);
console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
} catch (e) {
console.warn('[sheetStore] Failed to save to cache:', e);
}
}
productCodesLoading.set(false);
}

View file

@ -17,6 +17,38 @@ export const socketConnectionOfflineCount = writable<number>(0);
export const socketAlreadySendHeartbeat = writable<number>(0);
export const socketStore = writable<WebSocket | null>(null);
export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
const currentSocket = get(socketStore);
if (currentSocket?.readyState === WebSocket.OPEN) {
return Promise.resolve(currentSocket);
}
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(null);
}, timeoutMs);
function settle(nextSocket: WebSocket | null) {
if (settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(nextSocket);
}
unsubscribe = socketStore.subscribe((nextSocket) => {
if (nextSocket?.readyState === WebSocket.OPEN) {
settle(nextSocket);
}
});
});
}
export function connectToWebsocket(id_token?: string) {
if (browser) {
// console.log('connecting to ', env.PUBLIC_WSS);
@ -42,6 +74,13 @@ export function connectToWebsocket(id_token?: string) {
let auth_data = get(authStore);
let perms = get(permission);
// Debug: check if auth_data has uid
console.log('[WS Auth] Sending auth with:', {
uid: auth_data?.uid,
name: auth_data?.displayName,
email: auth_data?.email
});
sendMessage({
type: 'auth',
payload: {
@ -54,6 +93,7 @@ export function connectToWebsocket(id_token?: string) {
}
});
}
console.log(socket);
// heartbeat 10s
socketCheck = setInterval(() => {

View file

@ -0,0 +1,12 @@
export interface MenuItem {
row_index: number;
name_th: string;
name_en: string;
product_codes: string[];
}
export interface CatalogDetail {
country: string;
catalog: string;
items: MenuItem[];
}

View file

@ -54,6 +54,15 @@ export type OutMessage =
values: any;
};
}
| {
type: 'list_menu';
payload: {
user_info: any;
country: string;
boxid?: string;
};
}
| {
type: 'price';
payload: {

View file

@ -0,0 +1,162 @@
import { get } from 'svelte/store';
import { existingProductCodes } from '../stores/sheetStore';
// Country code mapping (country short from Android -> 2-digit product code prefix)
// Country short is read from /sdcard/coffeevending/country/short
export const countryCodeMap: Record<string, string> = {
// Thailand
THAI: '12',
tha: '12',
// Malaysia
MYS: '12',
mys: '12',
// Indonesia
IDR: '13',
idr: '13',
// Australia
AUS: '51',
aus: '51',
// Singapore
SGP: '52',
sgp: '52',
SG: '52', // obsolete
// UAE Dubai
UAE_DUBAI: '53',
uae_dubai: '53',
dubai: '53',
// Hong Kong
HKG: '54',
hkg: '54',
// UK
GBR: '55',
gbr: '55',
// Romania
ROU: '56',
rou: '56',
// Latvia
LVA: '57',
lva: '57',
// Estonia
EST: '58',
est: '58',
// Lithuania
LTU: '59',
ltu: '59',
// USA Pepsi (uses Thai prefix)
USA_PEPSI: '12',
usa_pepsi: '12',
// Counter machines
counter01: '19'
};
// Temperature codes
export const tempCodes: Record<TempType, string> = {
hot: '01',
cold: '02',
blend: '03'
};
// Category/Drink type codes
export const categoryOptions = [
{ value: '01', label: 'Coffee V1' },
{ value: '21', label: 'Coffee V2' },
{ value: '02', label: 'Tea' },
{ value: '03', label: 'Milk' },
{ value: '04', label: 'Whey' },
{ value: '05', label: 'Soda & Other' }
] as const;
export type TempType = 'hot' | 'cold' | 'blend';
export type CategoryCode = (typeof categoryOptions)[number]['value'];
/**
* Get all existing product code suffixes (last 4 digits)
* @param category - Optional category code to filter by (e.g., '01' for Coffee V1)
*/
export function getExistingCodeSuffixes(category?: string): Set<string> {
const codes = get(existingProductCodes);
if (category) {
// Filter codes by category (2nd segment: XX-[category]-XX-XXXX)
return new Set(
[...codes]
.filter((code) => {
const parts = code.split('-');
return parts[1] === category;
})
.map((code) => code.split('-')[3])
);
}
return new Set([...codes].map((code) => code.split('-')[3]));
}
/**
* Generate next suffix by finding max existing + 1 for a specific category
* @param category - Optional category code to find max within (e.g., '01' for Coffee V1)
*/
export function generateNextSuffix(category?: string): string {
const suffixes = getExistingCodeSuffixes(category);
let maxSuffix = 0;
for (const suffix of suffixes) {
const num = parseInt(suffix, 10);
if (!isNaN(num) && num > maxSuffix) {
maxSuffix = num;
}
}
const nextSuffix = maxSuffix + 1;
if (nextSuffix > 9999) {
throw new Error('Product code suffix exceeded 9999');
}
return String(nextSuffix).padStart(4, '0');
}
/**
* Generate a complete product code
* @param country - Country code (e.g., 'tha')
* @param category - Category code (e.g., '01' for Coffee V1)
* @param temp - Temperature type ('hot', 'cold', 'blend')
* @param suffix - Optional specific suffix, otherwise auto-generate
* @returns Product code like '12-01-01-0006'
*/
export function generateProductCode(
country: string,
category: string,
temp: TempType,
suffix?: string
): string {
const countryCode = countryCodeMap[country] || '99';
const tempCode = tempCodes[temp];
const codeSuffix = suffix ?? generateNextSuffix();
return `${countryCode}-${category}-${tempCode}-${codeSuffix}`;
}
/**
* Check if a product code already exists
*/
export function isProductCodeExists(code: string): boolean {
const codes = get(existingProductCodes);
return codes.has(code);
}
/**
* Generate a unique product code (auto-retry if exists)
*/
export function generateUniqueProductCode(
country: string,
category: string,
temp: TempType
): string {
const code = generateProductCode(country, category, temp);
if (isProductCodeExists(code)) {
throw new Error(`Product code already exists: ${code}`);
}
return code;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,121 @@
type AndroidRecipeExportRow = {
lineNumber: number;
cells: string[];
values: Record<string, string>;
};
type AndroidRecipeExportData = {
headers: string[];
rows: AndroidRecipeExportRow[];
lineCount: number;
};
type ParseRequest = {
id: number;
raw: string;
maxRows: number;
};
function normalizeHeader(value: string, index: number): string {
const header = value.trim().replace(/^\uFEFF/, '');
return header || `Column ${index + 1}`;
}
function splitTsvLine(line: string): string[] {
return line.split('\t').map((cell) => cell.trim());
}
function collectNonEmptyLines(raw: string, maxLines: number): string[] {
const lines: string[] = [];
let lineStart = 0;
for (let index = 0; index <= raw.length; index += 1) {
const isEnd = index === raw.length;
const char = raw[index];
if (!isEnd && char !== '\n') continue;
const lineEnd = index > lineStart && raw[index - 1] === '\r' ? index - 1 : index;
const line = raw.slice(lineStart, lineEnd);
if (line.trim().length > 0) {
lines.push(line);
if (lines.length >= maxLines) break;
}
lineStart = index + 1;
}
return lines;
}
function buildUniqueHeaders(rawHeaders: string[], maxColumns: number): string[] {
const headers = [...rawHeaders];
for (let i = headers.length; i < maxColumns; i += 1) {
headers.push(`Column ${i + 1}`);
}
const seen = new Map<string, number>();
return headers.map((header, index) => {
const normalized = normalizeHeader(header, index);
const count = seen.get(normalized) ?? 0;
seen.set(normalized, count + 1);
return count === 0 ? normalized : `${normalized} ${count + 1}`;
});
}
function parseAndroidRecipeExport(raw: string, maxRows: number): AndroidRecipeExportData {
const maxLines = Number.isFinite(maxRows) ? Math.max(1, maxRows + 1) : Number.MAX_SAFE_INTEGER;
const lines = collectNonEmptyLines(raw, maxLines);
if (lines.length === 0) {
return {
headers: [],
rows: [],
lineCount: 0
};
}
const parsedLines = lines.map(splitTsvLine);
const maxColumns = Math.max(...parsedLines.map((line) => line.length));
const headers = buildUniqueHeaders(parsedLines[0], maxColumns);
const rows = parsedLines.slice(1).map((cells, index) => {
const paddedCells = [...cells];
for (let cellIndex = paddedCells.length; cellIndex < headers.length; cellIndex += 1) {
paddedCells.push('');
}
const values = Object.fromEntries(
headers.map((header, cellIndex) => [header, paddedCells[cellIndex] ?? ''])
);
return {
lineNumber: index + 2,
cells: paddedCells,
values
};
});
return {
headers,
rows,
lineCount: lines.length
};
}
self.onmessage = (event: MessageEvent<ParseRequest>) => {
const { id, raw, maxRows } = event.data;
try {
const parsed = parseAndroidRecipeExport(raw, maxRows);
self.postMessage({ id, parsed });
} catch (error: any) {
self.postMessage({ id, error: error?.message ?? 'Unable to parse recipe export.' });
}
};
export {};

View file

@ -7,20 +7,38 @@
import '../layout.css';
import ErrorLayout from '$lib/components/error-layout.svelte';
import { sidebarStore } from '$lib/core/stores/sidebar';
import { onMount } from 'svelte';
import { auth } from '$lib/core/stores/auth';
import { get } from 'svelte/store';
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import { page } from '$app/stores';
import {
AdbDaemonWebUsbDevice,
AdbDaemonWebUsbDeviceManager
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
let { children } = $props();
let websocketConnectedForUid = $state('');
let adbReconnectTriedForUid = $state('');
function getAutoConnectChannel(pathname: string) {
if (pathname.startsWith('/tools/create-menu')) {
return 'recipe';
}
if (pathname.startsWith('/tools/brew')) {
return 'brew';
}
return 'adb';
}
async function tryAutoConnect() {
try {
if (adb.getAdbInstance()) return true;
if (!('usb' in navigator) || !AdbDaemonWebUsbDeviceManager.BROWSER) {
throw new Error('WebUSB not supported, try using fallback or different browser');
}
@ -38,7 +56,12 @@
const credStore = new AdbWebCredentialStore();
try {
await adb.connectDeviceByCred(device, credStore);
const channel = getAutoConnectChannel($page.url.pathname);
if (channel === 'recipe') {
await adb.connectRecipeMenuDeviceByCred(device, credStore);
} else {
await adb.connectDeviceByCred(device, credStore, channel === 'brew');
}
return true;
} catch (e: any) {
if (e.message === 'CREDENTIAL_EXPIRED') {
@ -61,24 +84,28 @@
}
}
onMount(async () => {
let currentUser = get(auth);
// console.log(`on mount layout current user: ${JSON.stringify(currentUser)}`);
if (currentUser) {
// console.log('id', await currentUser.getIdToken());
console.log('connect ws on mount');
connectToWebsocket(await currentUser.getIdToken());
await tryAutoConnect();
}
});
$effect(() => {
console.log('connect ws on effect');
const currentUser = $auth;
setTimeout(async () => {
connectToWebsocket(await get(auth)?.getIdToken());
}, 100);
if (!currentUser) {
websocketConnectedForUid = '';
adbReconnectTriedForUid = '';
return;
}
if (websocketConnectedForUid !== currentUser.uid) {
websocketConnectedForUid = currentUser.uid;
console.log('connect ws after auth ready');
void currentUser.getIdToken().then((idToken) => {
connectToWebsocket(idToken);
});
}
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
adbReconnectTriedForUid = currentUser.uid;
void tryAutoConnect();
}
});
</script>
@ -94,7 +121,7 @@
}}
>
<AppSidebar />
<main class="h-screen w-screen overflow-hidden">
<main class="h-screen w-screen overflow-auto">
<Sidebar.Trigger />
{@render children()}
</main>

View file

@ -72,10 +72,14 @@
<div class="flex h-full flex-col overflow-hidden p-4">
<div class="mb-4">
<h1 class="text-2xl font-bold">Admin Panel</h1>
<p class="text-muted-foreground text-sm">Manage users, roles, and system settings</p>
<p class="text-sm text-muted-foreground">Manage users, roles, and system settings</p>
</div>
<Tabs.Root value={activeTab} onValueChange={handleTabChange} class="flex flex-1 flex-col overflow-hidden">
<Tabs.Root
value={activeTab}
onValueChange={handleTabChange}
class="flex flex-1 flex-col overflow-hidden"
>
<Tabs.List class="grid w-full max-w-md grid-cols-3">
<Tabs.Trigger value="users" class="flex items-center gap-2">
<Users class="h-4 w-4" />

View file

@ -26,7 +26,7 @@
departmentStore.set(cnt);
if (refPage === 'sheet') {
await goto('/sheet/overview');
await goto(`/sheet/overview/${cnt}`);
} else {
await goto('/recipe/overview');
}

View file

@ -0,0 +1,846 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { addNotification } from '$lib/core/stores/noti.js';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { ArrowLeft, Plus, X, Search, RefreshCw } from '@lucide/svelte/icons';
import {
enterRoom,
exitRoom,
sendHeartbeat,
addMenu,
requestListMenu
} from '$lib/core/services/sheetService.js';
import {
existingProductCodes,
loadProductCodesFromCache,
clearProductCodes,
getSheetColumnConfig
} from '$lib/core/stores/sheetStore.js';
import * as adb from '$lib/core/adb/adb';
import { AdbInstance } from '../../../../../state.svelte';
import { recipeFromMachineQuery } from '$lib/core/stores/recipeStore';
// Route params
const country = $page.params.country!;
const catalog = $page.params.catalog!;
const countryCode = country.toLowerCase();
// Country language configuration
const countryLanguageConfig: Record<
string,
{ primary: string; secondary: string; primaryKey: string; secondaryKey: string }
> = {
tha: { primary: 'Thai', secondary: 'English', primaryKey: 'th', secondaryKey: 'en' },
thai: { primary: 'Thai', secondary: 'English', primaryKey: 'th', secondaryKey: 'en' },
mys: { primary: 'Malay', secondary: 'English', primaryKey: 'ms', secondaryKey: 'en' },
myn: { primary: 'Malay', secondary: 'English', primaryKey: 'ms', secondaryKey: 'en' },
idr: { primary: 'Indonesian', secondary: 'English', primaryKey: 'id', secondaryKey: 'en' },
sgp: { primary: 'English', secondary: 'Chinese', primaryKey: 'en', secondaryKey: 'zh' },
sg: { primary: 'English', secondary: 'Chinese', primaryKey: 'en', secondaryKey: 'zh' },
aus: { primary: 'English', secondary: '', primaryKey: 'en', secondaryKey: '' },
gbr: { primary: 'English', secondary: '', primaryKey: 'en', secondaryKey: '' },
ltu: { primary: 'Lithuanian', secondary: 'English', primaryKey: 'lt', secondaryKey: 'en' },
lva: { primary: 'Latvian', secondary: 'English', primaryKey: 'lv', secondaryKey: 'en' },
est: { primary: 'Estonian', secondary: 'English', primaryKey: 'et', secondaryKey: 'en' },
rou: { primary: 'Romanian', secondary: 'English', primaryKey: 'ro', secondaryKey: 'en' },
hkg: { primary: 'Cantonese', secondary: 'English', primaryKey: 'zh', secondaryKey: 'en' },
uae_dubai: { primary: 'Arabic', secondary: 'English', primaryKey: 'ar', secondaryKey: 'en' },
dubai: { primary: 'Arabic', secondary: 'English', primaryKey: 'ar', secondaryKey: 'en' }
};
const defaultLangConfig = {
primary: 'Primary',
secondary: 'English',
primaryKey: 'en',
secondaryKey: ''
};
const langConfig = countryLanguageConfig[countryCode] || defaultLangConfig;
// State
let saving = $state(false);
let lockTimeout = $state(30);
let timeoutInterval: ReturnType<typeof setInterval>;
let roomReleased = false;
// Form state - single menu item
let formData = $state({
name_primary: '',
name_secondary: '',
desc_primary: '',
desc_secondary: '',
image: '',
position: '',
categories: '',
// Additional data
notes: ''
});
// Product code state
let productCodes = $state<{ hot: string; cold: string; blend: string }>({
hot: '',
cold: '',
blend: ''
});
// Derive existing codes from store
const existingCodeSet = $derived($existingProductCodes);
// Get temp type from product code
function getTempFromCode(code: string): 'hot' | 'cold' | 'blend' | null {
const parts = code.split('-');
if (parts.length < 3) return null;
const tempCode = parts[2];
if (tempCode === '01') return 'hot';
if (tempCode === '02') return 'cold';
if (tempCode === '03') return 'blend';
return null;
}
// Load product codes from all sources
async function loadAvailableProductCodes() {
loadingCodes = true;
const codes: AvailableProductCode[] = [];
const seenCodes = new Set<string>();
const serverCodes = new Set<string>(); // Track server codes to identify new machine codes
try {
// 1. Load from server (existingProductCodes store)
for (const code of $existingProductCodes) {
const temp = getTempFromCode(code);
if (temp && !seenCodes.has(code)) {
seenCodes.add(code);
serverCodes.add(code);
codes.push({ code, source: 'server', temp });
}
}
// 2. Load from staged menus (localStorage)
try {
const stored = localStorage.getItem(stagedMenuStorageKey);
const stagedMenus = stored ? JSON.parse(stored) : [];
for (const menu of stagedMenus) {
const code = menu?.productCode;
const temp = getTempFromCode(code);
if (code && temp && !seenCodes.has(code)) {
seenCodes.add(code);
codes.push({
code,
name: menu.name || menu.otherName,
source: 'staged',
temp
});
}
}
} catch (e) {
console.error('Failed to load staged menus:', e);
}
// 3. Load from machine recipes (via recipeFromMachineQuery store)
const machineQuery = $recipeFromMachineQuery;
if (machineQuery?.recipe) {
for (const [code, recipe] of Object.entries(machineQuery.recipe)) {
const temp = getTempFromCode(code);
if (temp && !seenCodes.has(code)) {
seenCodes.add(code);
// Mark as new if not in server codes
const isNew = !serverCodes.has(code);
codes.push({
code,
name: (recipe as any)?.name || (recipe as any)?.otherName,
source: 'machine',
temp,
isNew
});
}
}
}
// Sort: new items first, then by code
codes.sort((a, b) => {
// New items first
if (a.isNew && !b.isNew) return -1;
if (!a.isNew && b.isNew) return 1;
// Then by code
return a.code.localeCompare(b.code);
});
availableProductCodes = codes;
} finally {
loadingCodes = false;
}
}
// Filter codes by current popup type and search query
const filteredCodes = $derived(() => {
let filtered = availableProductCodes.filter((c) => c.temp === codePopupType);
if (codeSearchQuery.trim()) {
const query = codeSearchQuery.toLowerCase().trim();
filtered = filtered.filter(
(c) => c.code.toLowerCase().includes(query) || c.name?.toLowerCase().includes(query)
);
}
// Maintain sort: new items first, then by code
return [...filtered].sort((a, b) => {
if (a.isNew && !b.isNew) return -1;
if (!a.isNew && b.isNew) return 1;
return a.code.localeCompare(b.code);
});
});
// Get source label
function getSourceLabel(source: ProductCodeSource): string {
switch (source) {
case 'server':
return 'Server';
case 'staged':
return 'Draft';
case 'machine':
return 'Machine';
}
}
// Get source badge variant
function getSourceVariant(source: ProductCodeSource): 'default' | 'secondary' | 'outline' {
switch (source) {
case 'server':
return 'default';
case 'staged':
return 'secondary';
case 'machine':
return 'outline';
}
}
// Popup state
let codePopupOpen = $state(false);
let codePopupType = $state<'hot' | 'cold' | 'blend'>('hot');
let codeSearchQuery = $state('');
let loadingCodes = $state(false);
// Available product codes from different sources
type ProductCodeSource = 'server' | 'staged' | 'machine';
type AvailableProductCode = {
code: string;
name?: string;
source: ProductCodeSource;
temp: 'hot' | 'cold' | 'blend';
isNew?: boolean; // true if from machine but not in server
};
let availableProductCodes = $state<AvailableProductCode[]>([]);
// Staged menus storage key (same as create-menu page)
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const tempLabels = {
hot: 'Hot',
cold: 'Cold',
blend: 'Blend'
};
const lockHeartbeatIntervalMs = 10000;
// Open popup for specific temperature type
function openCodePopup(type: 'hot' | 'cold' | 'blend') {
codePopupType = type;
codeSearchQuery = '';
codePopupOpen = true;
}
// Select a product code from the list
function selectCode(code: string) {
productCodes = {
...productCodes,
[codePopupType]: code
};
codePopupOpen = false;
}
// Clear a specific code
function clearCode(type: 'hot' | 'cold' | 'blend') {
productCodes = {
...productCodes,
[type]: ''
};
}
function getCatalogDisplayName(catalogName: string): string {
const match = catalogName.match(/page_catalog_group_(\w+)\.skt/);
return match ? match[1].charAt(0).toUpperCase() + match[1].slice(1) : catalogName;
}
function buildAddContent() {
// Build cells array according to backend format (20 columns).
// Names/descriptions (indices 2-5) are filled below per-country so each
// language lands in its correct sheet column.
const cells = [
'', // 0
'', // 1
'', // 2 - name -> col 4 (Thai/local primary slot)
'', // 3 - name -> col 3 (English slot)
'', // 4 - desc -> col 4
'', // 5 - desc -> col 3
productCodes.hot || '-', // 6 - Hot product code
productCodes.cold || '-', // 7 - Cold product code
productCodes.blend || '-', // 8 - Blend product code
formData.image || '', // 9 - Image filename
'-', // 10
'-', // 11
'-', // 12
formData.position || '', // 13 - Position
'-', // 14
'-', // 15
'-', // 16
'-', // 17
formData.categories || '', // 18 - Categories (comma-separated)
'' // 19
];
// lang_name/lang_desc map to sheet columns 5, 6, 7, 8 respectively.
const lang_name = ['-', '-', '-', '-'];
const lang_desc = ['-', '-', '-', '-'];
// Backend slot mapping (see taobin_sheet/main.py handle_add_menu):
// col 3 <- cells[3], col 4 <- cells[2]
// col 5 <- lang_name[0] ... col 8 <- lang_name[3]
function setNameForColumn(col: number, value: string) {
if (col === 3) cells[3] = value;
else if (col === 4) cells[2] = value;
else if (col >= 5 && col <= 8) lang_name[col - 5] = value || '-';
}
function setDescForColumn(col: number, value: string) {
if (col === 3) cells[5] = value;
else if (col === 4) cells[4] = value;
else if (col >= 5 && col <= 8) lang_desc[col - 5] = value || '-';
}
// Resolve which sheet column each language occupies for this country.
const columnConfig = getSheetColumnConfig(countryCode);
const languageColumns = columnConfig.language; // { en: 3, th: 4, lt: 5, ... }
const enColumn = languageColumns.en ?? 3;
const primaryColumn = languageColumns[columnConfig.primaryLanguage] ?? 4;
if (columnConfig.primaryLanguage === 'en') {
// Single-language (English) machine: primary field is English -> col 3.
setNameForColumn(enColumn, formData.name_primary);
setDescForColumn(enColumn, formData.desc_primary);
} else {
// English is the secondary field -> col 3.
setNameForColumn(enColumn, formData.name_secondary);
setDescForColumn(enColumn, formData.desc_secondary);
// Local primary language -> its configured column.
setNameForColumn(primaryColumn, formData.name_primary);
setDescForColumn(primaryColumn, formData.desc_primary);
// Hong Kong has two primary columns (Mandarin Simplified + Traditional);
// fill both from the single primary input.
if (languageColumns.zh_hans != null && languageColumns.zh_hant != null) {
setNameForColumn(languageColumns.zh_hans, formData.name_primary);
setDescForColumn(languageColumns.zh_hans, formData.desc_primary);
setNameForColumn(languageColumns.zh_hant, formData.name_primary);
setDescForColumn(languageColumns.zh_hant, formData.desc_primary);
}
}
const payload = { lang_name, lang_desc };
return [{ cells, payload }];
}
function validateForm(): string | null {
if (!formData.name_primary && !formData.name_secondary) {
return `Please enter at least ${langConfig.primary} or ${langConfig.secondary} name`;
}
if (!productCodes.hot && !productCodes.cold && !productCodes.blend) {
return 'Please add at least one product code';
}
return null;
}
function resetForm() {
formData = {
name_primary: '',
name_secondary: '',
desc_primary: '',
desc_secondary: '',
image: '',
position: '',
categories: '',
notes: ''
};
productCodes = { hot: '', cold: '', blend: '' };
}
function releaseRoom() {
if (roomReleased) return;
roomReleased = true;
exitRoom(country, catalog);
}
async function handleSave() {
const validationError = validateForm();
if (validationError) {
addNotification(`WARN:${validationError}`);
return;
}
saving = true;
try {
const content = buildAddContent();
const sent = addMenu(country, catalog, content);
if (!sent) {
throw new Error('WebSocket not connected. Cannot add menu.');
}
addNotification('INFO:Menu added successfully');
resetForm();
// Mark that a new menu was just added (edit page will detect and highlight it)
try {
const key = `sheet.newlyAdded.${country}.${catalog}`;
sessionStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), pending: true }));
} catch (e) {
console.warn('Failed to store newly added marker:', e);
}
// Go back to edit page after successful add
setTimeout(() => {
goto(`/sheet/edit/${country}/${catalog}`);
}, 1000);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
addNotification(`ERR:${errorMsg}`);
} finally {
saving = false;
}
}
async function handleCancel() {
releaseRoom();
clearInterval(timeoutInterval);
goto(`/sheet/edit/${country}/${catalog}`);
}
// Track previous size to detect changes
let previousCodeCount = $state(0);
// Re-load available product codes when existingProductCodes store changes
$effect(() => {
const currentSize = $existingProductCodes.size;
if (currentSize > 0 && currentSize !== previousCodeCount) {
previousCodeCount = currentSize;
console.log('[Add] existingProductCodes updated, reloading available codes:', currentSize);
loadAvailableProductCodes();
}
});
onMount(async () => {
// Clear old product codes and load from cache for this country
clearProductCodes();
loadProductCodesFromCache(countryCode);
// Get boxid from connected machine if available
let boxid: string | undefined;
if (adb.getAdbInstance()) {
try {
boxid = (await adb.pull('/sdcard/coffeevending/.bid')) || undefined;
} catch (e) {
console.warn('Failed to get boxid from machine:', e);
}
}
requestListMenu(country, boxid);
// Load available product codes from all sources
await loadAvailableProductCodes();
// Enter room to get lock
const entered = enterRoom(country, catalog);
if (entered) {
addNotification(`INFO:Entered ${getCatalogDisplayName(catalog)} for adding menu`);
// Keep the room alive
timeoutInterval = setInterval(() => {
sendHeartbeat(country, catalog);
lockTimeout = 30;
}, lockHeartbeatIntervalMs);
} else {
addNotification('ERR:WebSocket not connected');
goto(`/sheet/overview/${country}`);
}
});
onDestroy(() => {
clearInterval(timeoutInterval);
releaseRoom();
});
</script>
<!-- Wrapper -->
<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 class="flex items-center gap-4">
<Button variant="ghost" size="sm" onclick={handleCancel}>
<ArrowLeft class="mr-2 h-4 w-4" />
Back
</Button>
<div>
<h1 class="text-2xl font-bold">
Add Menu: {getCatalogDisplayName(catalog)}
</h1>
<p class="text-sm text-muted-foreground">
{country.toUpperCase()}{catalog}
</p>
</div>
</div>
<div class="flex items-center gap-4">
<Badge variant={lockTimeout > 10 ? 'default' : 'destructive'}>
Lock: {lockTimeout}s
</Badge>
<Button variant="outline" onclick={handleCancel} disabled={saving}>
<X class="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onclick={handleSave} disabled={saving}>
{#if saving}
<Spinner class="mr-2 h-4 w-4" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Add Menu
</Button>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-8">
<div class="mx-auto max-w-4xl space-y-6">
<!-- Basic Info -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span class="text-sm font-bold text-primary">1</span>
</div>
Basic Information
</Card.Title>
<Card.Description>Enter menu name and description</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="name_primary">{langConfig.primary} Name *</Label>
<Input
id="name_primary"
bind:value={formData.name_primary}
placeholder="Menu name in {langConfig.primary}"
class="h-11"
/>
</div>
{#if langConfig.secondary}
<div class="space-y-2">
<Label for="name_secondary">{langConfig.secondary} Name *</Label>
<Input
id="name_secondary"
bind:value={formData.name_secondary}
placeholder="Menu name in {langConfig.secondary}"
class="h-11"
/>
</div>
{/if}
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="desc_primary">{langConfig.primary} Description</Label>
<textarea
id="desc_primary"
bind:value={formData.desc_primary}
placeholder="Description in {langConfig.primary}"
class="min-h-20 w-full rounded-md border bg-background px-3 py-2 text-sm"
rows="3"
></textarea>
</div>
{#if langConfig.secondary}
<div class="space-y-2">
<Label for="desc_secondary">{langConfig.secondary} Description</Label>
<textarea
id="desc_secondary"
bind:value={formData.desc_secondary}
placeholder="Description in {langConfig.secondary}"
class="min-h-20 w-full rounded-md border bg-background px-3 py-2 text-sm"
rows="3"
></textarea>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<!-- Product Codes -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span class="text-sm font-bold text-primary">2</span>
</div>
Product Codes
</Card.Title>
<Card.Description>
Click on each field to select category and generate code
{#if existingCodeSet.size > 0}
({existingCodeSet.size} existing codes)
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 md:grid-cols-3">
<!-- Hot Code -->
<div class="space-y-2">
<Label>Hot Code</Label>
<button
type="button"
onclick={() => openCodePopup('hot')}
class="flex h-11 w-full items-center justify-between rounded-md border bg-background px-3 text-left transition-colors hover:border-orange-500/50 hover:bg-orange-500/5"
>
{#if productCodes.hot}
<span class="font-mono text-sm">{productCodes.hot}</span>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
clearCode('hot');
}}
class="text-muted-foreground hover:text-destructive"
>
<X class="h-4 w-4" />
</button>
{:else}
<span class="text-sm text-muted-foreground">Click to add...</span>
<Plus class="h-4 w-4 text-muted-foreground" />
{/if}
</button>
</div>
<!-- Cold Code -->
<div class="space-y-2">
<Label>Cold Code</Label>
<button
type="button"
onclick={() => openCodePopup('cold')}
class="flex h-11 w-full items-center justify-between rounded-md border bg-background px-3 text-left transition-colors hover:border-blue-500/50 hover:bg-blue-500/5"
>
{#if productCodes.cold}
<span class="font-mono text-sm">{productCodes.cold}</span>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
clearCode('cold');
}}
class="text-muted-foreground hover:text-destructive"
>
<X class="h-4 w-4" />
</button>
{:else}
<span class="text-sm text-muted-foreground">Click to add...</span>
<Plus class="h-4 w-4 text-muted-foreground" />
{/if}
</button>
</div>
<!-- Blend Code -->
<div class="space-y-2">
<Label>Blend Code</Label>
<button
type="button"
onclick={() => openCodePopup('blend')}
class="flex h-11 w-full items-center justify-between rounded-md border bg-background px-3 text-left transition-colors hover:border-purple-500/50 hover:bg-purple-500/5"
>
{#if productCodes.blend}
<span class="font-mono text-sm">{productCodes.blend}</span>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
clearCode('blend');
}}
class="text-muted-foreground hover:text-destructive"
>
<X class="h-4 w-4" />
</button>
{:else}
<span class="text-sm text-muted-foreground">Click to add...</span>
<Plus class="h-4 w-4 text-muted-foreground" />
{/if}
</button>
</div>
</div>
<!-- Code Format Info -->
<!-- <div class="mt-4 rounded-lg border border-muted bg-muted/20 p-3">
<p class="text-xs text-muted-foreground">
<strong>Format:</strong> {countryCodeMap[country] || '??'}-[category]-[temp]-[random] •
Country: {country.toUpperCase()}
01=Hot, 02=Cold, 03=Blend
</p>
</div> -->
</Card.Content>
</Card.Root>
<!-- Image & Categories -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span class="text-sm font-bold text-primary">3</span>
</div>
Image & Categories
</Card.Title>
<Card.Description>Set image filename and menu categories</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="image">Image Filename</Label>
<Input
id="image"
bind:value={formData.image}
placeholder="bn_hot_america_no.png"
class="font-mono"
/>
</div>
<div class="space-y-2">
<Label for="position">Position</Label>
<Input id="position" bind:value={formData.position} placeholder="posi1" />
</div>
</div>
<div class="mt-4 space-y-2">
<Label for="categories">Categories (comma-separated)</Label>
<Input
id="categories"
bind:value={formData.categories}
placeholder="Coffee,CoffeeNoMilk,ShakeShake"
/>
<!-- <p class="text-xs text-muted-foreground">
Separate multiple categories with commas
</p> -->
</div>
</Card.Content>
</Card.Root>
<!-- Actions -->
<div class="flex justify-end gap-3 pb-8">
<Button variant="outline" size="lg" onclick={handleCancel} disabled={saving}>
<X class="mr-2 h-4 w-4" />
Cancel
</Button>
<Button size="lg" onclick={handleSave} disabled={saving}>
{#if saving}
<Spinner class="mr-2 h-4 w-4" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Add Menu
</Button>
</div>
</div>
</div>
</div>
<!-- Product Code Selection Popup -->
<Dialog.Root bind:open={codePopupOpen}>
<Dialog.Content class="max-h-[80vh] sm:max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
Select {tempLabels[codePopupType]} Product Code
</Dialog.Title>
<Dialog.Description>
Choose from existing product codes (Server, Draft, or Machine)
</Dialog.Description>
</Dialog.Header>
<!-- Search and Refresh -->
<div class="mt-4 flex gap-2">
<div class="relative flex-1">
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search by code or name..."
bind:value={codeSearchQuery}
class="pl-9"
/>
</div>
<Button
variant="outline"
size="icon"
onclick={() => loadAvailableProductCodes()}
disabled={loadingCodes}
>
<RefreshCw class="h-4 w-4 {loadingCodes ? 'animate-spin' : ''}" />
</Button>
</div>
<!-- Code List -->
<div class="mt-4 max-h-[400px] space-y-2 overflow-y-auto">
{#if loadingCodes}
<div class="flex items-center justify-center py-8">
<Spinner class="h-6 w-6" />
<span class="ml-2 text-muted-foreground">Loading codes...</span>
</div>
{:else if filteredCodes().length === 0}
<div class="py-8 text-center text-muted-foreground">
{#if codeSearchQuery}
No codes found matching "{codeSearchQuery}"
{:else}
No {tempLabels[codePopupType].toLowerCase()} product codes available
{/if}
</div>
{:else}
{#each filteredCodes() as item}
<button
type="button"
onclick={() => selectCode(item.code)}
class="flex w-full items-center justify-between rounded-lg border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5 {item.isNew ? 'border-green-500/50 bg-green-500/5' : ''}"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm">{item.code}</span>
{#if item.isNew}
<Badge class="bg-green-600 hover:bg-green-600 text-[10px] px-1.5 py-0">NEW</Badge>
{/if}
</div>
{#if item.name}
<div class="truncate text-sm text-muted-foreground">{item.name}</div>
{/if}
</div>
<Badge variant={getSourceVariant(item.source)} class="ml-2 shrink-0">
{getSourceLabel(item.source)}
</Badge>
</button>
{/each}
{/if}
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-xs text-muted-foreground">
{filteredCodes().length} code{filteredCodes().length !== 1 ? 's' : ''} available
</div>
<Button variant="outline" onclick={() => (codePopupOpen = false)}>Cancel</Button>
</div>
</Dialog.Content>
</Dialog.Root>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import { get } from 'svelte/store';
export function load() {
// Set reference so departments page knows to redirect to sheet
referenceFromPage.set('sheet');
// Redirect to departments page to select country
throw redirect(302, '/departments');
}

View file

@ -1,66 +1,149 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { SearchIcon } from '@lucide/svelte/icons';
import { onDestroy, onMount } from 'svelte';
import {
recipeData,
recipeFromServerQuery,
recipeOverviewData,
referenceFromPage
} from '$lib/core/stores/recipeStore.js';
import { sendCommandRequest, sendMessage } from '$lib/core/handlers/ws_messageSender.js';
import { auth } from '$lib/core/stores/auth.js';
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { getRecipes } from '$lib/core/client/server.js';
import { departmentStore } from '$lib/core/stores/departments';
import { addNotification } from '$lib/core/stores/noti.js';
import { departmentStore } from '$lib/core/stores/departments.js';
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
import {
sheetCatalogs,
sheetCatalogsLoading,
type Catalog
} from '$lib/core/stores/sheetStore.js';
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
import { requestCatalogs } from '$lib/core/services/sheetService.js';
let refDepartment: string | undefined = $state();
import Button from '$lib/components/ui/button/button.svelte';
import * as Table from '$lib/components/ui/table/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { RefreshCw } from '@lucide/svelte/icons';
onMount(async () => {
// do load recipe
refDepartment = get(departmentStore);
referenceFromPage.set('overview');
// Get country from route params or department store
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
let catalogs = $derived($sheetCatalogs);
let loading = $derived($sheetCatalogsLoading);
let error = $state<string | null>(null);
sendCommandRequest('sheet', {
country: refDepartment,
param: 'catalogs'
});
onMount(() => {
referenceFromPage.set('sheet');
// await getRecipes();
if (selectedCountry) {
void loadCatalogs();
}
});
// onDestroy(() => {
// unsubRecipeData();
// });
async function loadCatalogs() {
if (!selectedCountry) return;
error = null;
sheetCatalogsLoading.set(true);
sheetCatalogs.set([]);
const socket = await waitForOpenSocket();
if (!socket) {
error = 'WebSocket is still connecting. Please try again.';
sheetCatalogsLoading.set(false);
addNotification('ERR:WebSocket not connected');
return;
}
const sent = requestCatalogs(selectedCountry);
if (!sent) {
error = 'WebSocket not connected. Please try again.';
sheetCatalogsLoading.set(false);
addNotification('ERR:WebSocket not connected');
}
}
function handleEditCatalog(catalog: Catalog) {
if (catalog.status === 'locked') {
addNotification(`WARN:Catalog is locked by ${catalog.locked_by}`);
return;
}
goto(`/sheet/edit/${selectedCountry}/${catalog.catalog}`);
}
</script>
<div class="mx-8 flex">
<!-- header -->
<div class="w-full">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Layout overview [ {refDepartment} ]</h1>
<h1 class="m-8 text-4xl font-bold">Sheet Overview</h1>
<p class="mx-8 my-0 text-muted-foreground">
Display menus from the spreadsheet current selected country
Catalogs for {selectedCountry.toUpperCase()}
</p>
</div>
<div class="mx-8 my-4 flex gap-2">
<Button variant="default">+ Create Menu</Button>
<div class="mr-8">
<Button variant="outline" onclick={loadCatalogs} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
<!-- search bar -->
<!-- <div class="mx-4 my-8 flex w-full items-center justify-center gap-2">
<SearchIcon />
<Input type="text" placeholder="Search by id, product code, name or material" class="" />
</div> -->
<!-- filter -->
<!-- table -->
<!-- <div class="w-full overflow-auto">
<DataTable data={data.recipes} refPage="overview" {columns} />
</div> -->
<!-- Content Area -->
<div class="mx-8">
{#if loading}
<div class="flex h-64 items-center justify-center">
<Spinner class="h-12 w-12" />
<p class="ml-4 text-muted-foreground">
Loading catalogs for {selectedCountry}...
</p>
</div>
{:else if error}
<div class="rounded-md border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-600">{error}</p>
<Button variant="outline" size="sm" class="mt-2" onclick={loadCatalogs}>
<RefreshCw class="mr-2 h-4 w-4" />
Retry
</Button>
</div>
{:else if catalogs.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
<p>No catalogs found for {selectedCountry}</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Catalog Name</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Locked By</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each catalogs as catalog}
<Table.Row>
<Table.Cell class="font-medium">{catalog.catalog}</Table.Cell>
<Table.Cell>
<Badge
variant={catalog.status === 'free' ? 'default' : 'secondary'}
>
{catalog.status.toUpperCase()}
</Badge>
</Table.Cell>
<Table.Cell class="text-muted-foreground">
{catalog.locked_by || '-'}
</Table.Cell>
<Table.Cell>
<Button
size="sm"
variant={catalog.status === 'free' ? 'default' : 'outline'}
onclick={() => handleEditCatalog(catalog)}
disabled={catalog.status === 'locked'}
>
{catalog.status === 'free' ? 'Edit' : 'Locked'}
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,279 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
import { addNotification } from '$lib/core/stores/noti.js';
import { departmentStore } from '$lib/core/stores/departments.js';
import {
sheetCatalogs,
sheetCatalogsLoading,
type Catalog
} from '$lib/core/stores/sheetStore.js';
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
import { requestCatalogs } from '$lib/core/services/sheetService.js';
import Button from '$lib/components/ui/button/button.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { ArrowRight, Lock, RefreshCw, Star } from '@lucide/svelte/icons';
const mainCatalogNames = [
'page_catalog_group_coffee.skt',
'page_catalog_group_tea.skt',
'page_catalog_group_milk.skt',
'page_catalog_group_dessert.skt',
'page_catalog_group_whey.skt',
'page_catalog_group_health.skt',
'page_catalog_group_pepsi_7up.skt',
'page_catalog_group_other_other.skt'
];
const mainCatalogRank = new Map(mainCatalogNames.map((name, index) => [name, index]));
function isMainCatalog(catalogName: string): boolean {
return mainCatalogRank.has(catalogName);
}
function compareCatalogs(a: Catalog, b: Catalog): number {
const rankA = mainCatalogRank.get(a.catalog);
const rankB = mainCatalogRank.get(b.catalog);
if (rankA !== undefined && rankB !== undefined) return rankA - rankB;
if (rankA !== undefined) return -1;
if (rankB !== undefined) return 1;
return 0;
}
// Helper function to extract display name from catalog filename
function getCatalogDisplayName(catalogName: string): string {
const match = catalogName.match(/page_catalog_group_(\w+)\.skt/);
if (match && match[1]) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return catalogName;
}
let selectedCountry = $state<string>($page.params.country || '');
let catalogs = $derived($sheetCatalogs);
let sortedCatalogs = $derived([...catalogs].sort(compareCatalogs));
let loading = $derived($sheetCatalogsLoading);
let error = $state<string | null>(null);
let enabledCountries = $state<string[]>([]);
onMount(() => {
// Set department store
if (selectedCountry) {
departmentStore.set(selectedCountry);
}
// Extract enabled countries from permissions
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
enabledCountries = userPerms.map((x) => x.split('.')[2]);
// Auto-load catalogs for the selected country
if (selectedCountry) {
void loadCatalogs();
}
// Retry permissions after 1 second if empty
if (enabledCountries.length === 0) {
setTimeout(() => {
const retryPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
enabledCountries = retryPerms.map((x) => x.split('.')[2]);
}, 1000);
}
});
async function loadCatalogs() {
if (!selectedCountry) return;
error = null;
sheetCatalogsLoading.set(true);
sheetCatalogs.set([]);
const socket = await waitForOpenSocket();
if (!socket) {
error = 'WebSocket is still connecting. Please try again.';
sheetCatalogsLoading.set(false);
addNotification('ERR:WebSocket not connected');
return;
}
const sent = requestCatalogs(selectedCountry);
if (!sent) {
error = 'WebSocket not connected. Please try again.';
sheetCatalogsLoading.set(false);
addNotification('ERR:WebSocket not connected');
}
}
function handleEditCatalog(catalog: Catalog) {
if (catalog.status === 'locked') {
addNotification(`WARN:Catalog is locked by ${catalog.locked_by}`);
return;
}
goto(`/sheet/edit/${selectedCountry}/${catalog.catalog}`);
}
</script>
<div
class="min-h-screen bg-background dark:bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.10),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.20),transparent_34%)]"
>
<div class="w-full px-8 py-8">
<div class="mb-8 flex items-start justify-between gap-6">
<div class="min-w-0">
<h1 class="text-4xl leading-tight font-bold tracking-normal">
Sheet Overview [ {selectedCountry.toUpperCase()} ]
</h1>
<p class="mt-7 text-lg text-muted-foreground">
View available catalogs for {selectedCountry.toUpperCase()}
</p>
</div>
<Button
variant="outline"
class="h-12 rounded-lg border-border/80 px-5 font-semibold"
onclick={loadCatalogs}
disabled={loading}
>
{#if loading}
<Spinner class="mr-2 h-4 w-4" />
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{/if}
Refresh
</Button>
</div>
<div class="mb-7">
{#if enabledCountries.length === 0}
<p class="text-sm text-muted-foreground">
No countries available. Please check your permissions.
</p>
{:else}
<Select.Root
type="single"
value={selectedCountry}
onValueChange={(v) => {
if (v) {
selectedCountry = v;
goto(`/sheet/overview/${v}`);
}
}}
>
<Select.Trigger
class="h-11 w-64 rounded-lg border-border/80 bg-card/70 px-4 font-semibold"
>
{selectedCountry.toUpperCase()}
</Select.Trigger>
<Select.Content>
{#each enabledCountries as country}
<Select.Item value={country}>
{country.toUpperCase()}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
</div>
<div>
{#if loading}
<div class="flex h-64 items-center justify-center rounded-lg border bg-card/50">
<Spinner class="h-12 w-12" />
<p class="ml-4 text-muted-foreground">
Loading catalogs for {selectedCountry}...
</p>
</div>
{:else if error}
<div class="rounded-lg border border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/50">
<p class="text-sm text-red-700 dark:text-red-400">{error}</p>
<Button variant="outline" size="sm" class="mt-2" onclick={loadCatalogs}>
<RefreshCw class="mr-2 h-4 w-4" />
Retry
</Button>
</div>
{:else if sortedCatalogs.length === 0}
<div
class="flex h-64 items-center justify-center rounded-lg border bg-card/50 text-muted-foreground"
>
<p>No catalogs found for {selectedCountry}</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
{#each sortedCatalogs as catalog}
<Card.Root
class="group min-h-[234px] rounded-xl border-border/80 bg-card/80 shadow-sm transition-colors hover:border-border hover:bg-card"
>
<Card.Header class="px-6 pt-7 pb-4">
<div class="flex items-start justify-between gap-3">
<Card.Title class="text-xl font-bold tracking-normal">
{getCatalogDisplayName(catalog.catalog)} group
</Card.Title>
{#if isMainCatalog(catalog.catalog)}
<Badge
variant="secondary"
class="inline-flex shrink-0 items-center gap-1 rounded-full border-yellow-500/30 bg-yellow-500/15 px-2.5 py-1 text-xs text-yellow-700 dark:text-yellow-200"
>
<Star class="h-3 w-3 fill-yellow-500 text-yellow-500 dark:fill-yellow-300 dark:text-yellow-300" />
Main
</Badge>
{/if}
</div>
<!-- <p class="mt-3 truncate text-sm text-muted-foreground">
{catalog.catalog}
</p> -->
</Card.Header>
<Card.Content class="px-6 pb-4">
<div class="mt-3 flex min-h-8 items-center gap-3">
{#if catalog.status === 'free'}
<Badge
class="inline-flex rounded-full bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-600"
>
<span class="mr-2 h-1.5 w-1.5 rounded-full bg-green-200"></span>
Available
</Badge>
<span class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Ready to edit
</span>
{:else}
<Badge
variant="secondary"
class="flex items-center gap-1 rounded-full px-3 py-1"
>
<Lock class="h-3 w-3" />
Locked
</Badge>
<span class="truncate text-sm text-muted-foreground">
{catalog.locked_by || 'In use'}
</span>
{/if}
</div>
</Card.Content>
<Card.Footer class="px-6 pt-2 pb-6">
<Button
variant="outline"
disabled={catalog.status === 'locked'}
class="h-11 w-full justify-center rounded-lg border-border/80 bg-background/35 font-semibold transition-colors group-hover:bg-background/55"
onclick={() => handleEditCatalog(catalog)}
>
<span class="flex-1 text-center">
{catalog.status === 'free' ? 'Edit' : 'Locked'}
</span>
{#if catalog.status === 'free'}
<ArrowRight class="h-4 w-4" />
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{/if}
</div>
</div>
</div>

View file

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

View file

@ -0,0 +1,42 @@
<script lang="ts">
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { onMount } from 'svelte';
let AndroidRecipeExportView = $state<any>(null);
let loadError = $state('');
onMount(async () => {
try {
const module = await import('$lib/components/android-recipe-export-view.svelte');
AndroidRecipeExportView = module.default;
} catch (error: any) {
loadError = error?.message ?? 'Unable to load Android recipe export.';
}
});
</script>
{#if AndroidRecipeExportView}
<AndroidRecipeExportView />
{:else}
<div class="mx-auto flex w-full max-w-[1600px] flex-col gap-6 px-8 py-8">
<div class="space-y-2">
<h1 class="text-4xl font-bold tracking-normal">Android Recipe Export</h1>
<p class="text-muted-foreground">Preparing recipe export viewer.</p>
</div>
<div class="rounded-lg border border-border bg-card p-8">
{#if loadError}
<h2 class="text-xl font-semibold text-destructive">Unable to load viewer</h2>
<p class="mt-2 text-muted-foreground">{loadError}</p>
{:else}
<div class="flex items-center gap-3">
<Spinner class="h-6 w-6" />
<div>
<h2 class="text-xl font-semibold">Processing</h2>
<p class="mt-1 text-muted-foreground">Loading Android recipe tools...</p>
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,7 +1,8 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { onMount } from 'svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { onMount, onDestroy } from 'svelte';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
@ -26,11 +27,20 @@
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { afterNavigate } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { fade } from 'svelte/transition';
import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter';
import { AdbInstance } from '../../../state.svelte';
import {
setOnMenuSavedCallback,
clearOnMenuSavedCallback,
clearMenuSaveState
} from '$lib/core/stores/menuSaveStore';
const sourceDir = '/sdcard/coffeevending';
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
// fetched recipe
let devRecipe: any | undefined = $state();
@ -44,43 +54,63 @@
// refresh command
let refresh_counter: number = $state(0);
let stagedMenus: any[] = $state([]);
let brewConfirmOpen = $state(false);
let pendingBrewMenu: any | null = $state(null);
let recipeLoading = $state(false);
let recipeAutoLoadAttempted = $state(false);
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
let isRecipeLoaded = $derived(Boolean(devRecipe));
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
for (let attempt = 1; attempt <= attempts; attempt++) {
const content = await adb.pull(path, timeoutMs);
if (content != undefined) return content;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
}
async function startFetchRecipeFromMachine() {
if (recipeLoading) return;
let instance = adb.getAdbInstance();
// recipeFromMachineLoading.set(true);
referenceFromPage.set('brew');
console.log('check instance', instance);
if (instance) {
console.log('instance passed!');
let dev_recipe = await adb.pull(`${sourceDir}/cfg/recipe_branch_dev.json`);
console.log('dev recipe ok', dev_recipe != undefined);
if (dev_recipe == undefined || dev_recipe == null || dev_recipe?.length == 0) {
dev_recipe = await adb.pull(`${sourceDir}/coffeethai02.json`);
console.log('dev recipe ok by production', dev_recipe != undefined);
}
if (dev_recipe) {
if (dev_recipe.length == 0) {
// case error, do last retry
dev_recipe = await adb.pull(`${sourceDir}/coffeethai02.json`);
recipeLoading = true;
try {
console.log('instance passed!');
const recipePaths = [
`${sourceDir}/cfg/recipe_branch_dev.json`,
`${sourceDir}/coffeethai02.json`
];
if (dev_recipe && dev_recipe.length == 0)
addNotification('ERROR:Cannot fetch recipe from machine');
else if (dev_recipe) {
// From coffeethai02
for (const recipePath of recipePaths) {
const dev_recipe = await pullTextWithRetry(recipePath);
console.log('dev recipe pull result', {
recipePath,
loaded: dev_recipe != undefined,
size: dev_recipe?.length ?? 0
});
if (!dev_recipe || dev_recipe.trim().length == 0) continue;
try {
devRecipe = JSON.parse(dev_recipe);
// recipeFromMachineLoading.set(false);
addNotification('INFO:Fetch recipe success!');
buildOverviewForBrewing();
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
}
} else {
// from recipe_branch_dev
devRecipe = JSON.parse(dev_recipe);
// recipeFromMachineLoading.set(false);
// addNotification('INFO:Fetch recipe success!');
buildOverviewForBrewing();
}
addNotification('ERROR:Cannot fetch recipe from machine');
} finally {
recipeLoading = false;
}
} else {
addNotification('ERROR:Cannot connect to machine');
@ -126,6 +156,14 @@
async function connectAdb() {
try {
if (adb.getAdbInstance()) {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidServer();
}
await loadBrewDataFromConnectedAdb();
return;
}
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback method or different browser');
}
@ -134,16 +172,28 @@
let instance = adb.getAdbInstance();
if (instance) {
await startFetchRecipeFromMachine();
await loadEssentialFiles();
await loadBrewDataFromConnectedAdb();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
}
}
async function loadBrewDataFromConnectedAdb() {
await startFetchRecipeFromMachine();
await loadEssentialFiles();
await loadStagedMenusFromAndroid();
}
async function tryAutoConnect() {
try {
if (adb.getAdbInstance()) {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidServer();
}
return true;
}
if (!('usb' in navigator) || !AdbDaemonWebUsbDeviceManager.BROWSER) {
throw new Error('WebUSB not supported, try using fallback method or different browser');
}
@ -183,6 +233,24 @@
}
}
async function ensureAndroidSocket() {
if (isAdbWriterAvailable()) return true;
if (!adb.getAdbInstance()) {
addNotification('ERR:ADB is not connected');
return false;
}
await adb.reconnectAndroidServer();
if (!isAdbWriterAvailable()) {
addNotification('ERR:Android socket is not connected');
return false;
}
return true;
}
afterNavigate(async () => {
console.log('after navigate brew');
await startFetchRecipeFromMachine();
@ -319,6 +387,20 @@
}
}
for (const stagedMenu of stagedMenus) {
if (!recipe01_query[stagedMenu.productCode]) {
data.recipes.push({
productCode: stagedMenu.productCode ?? '<not set>',
name: stagedMenu.name ? stagedMenu.name : (stagedMenu.otherName ?? '<not set>'),
description: stagedMenu.Description
? stagedMenu.Description
: (stagedMenu.otherDescription ?? '<not set>'),
tags: buildTags(stagedMenu),
status: 'drafted'
});
}
}
let materialFromMachine = devRecipe['MaterialSetting'];
let currentQuery = get(recipeFromMachineQuery);
@ -341,16 +423,330 @@
}
}
function findRecipeByProductCode(productCode: string) {
if (!devRecipe?.Recipe01) return null;
for (const recipe of devRecipe.Recipe01) {
if (recipe?.productCode === productCode) return recipe;
for (const subMenu of recipe?.SubMenu ?? []) {
if (subMenu?.productCode === productCode) return subMenu;
}
}
return null;
}
function isToppingSlotMaterial(materialId: number) {
return materialId > 8110 && materialId < 8131;
}
function getDeletedStagedMenuCodes() {
try {
const stored = localStorage.getItem(deletedStagedMenuStorageKey);
const parsed = stored ? JSON.parse(stored) : [];
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
} catch (error) {
return new Set<string>();
}
}
function persistDeletedStagedMenuCodes(codes: Set<string>) {
localStorage.setItem(deletedStagedMenuStorageKey, JSON.stringify([...codes]));
}
function markDeletedStagedMenu(productCode: string) {
const deletedCodes = getDeletedStagedMenuCodes();
deletedCodes.add(productCode);
persistDeletedStagedMenuCodes(deletedCodes);
}
function persistStagedMenus() {
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
void persistStagedMenusToAndroid();
}
async function persistStagedMenusToAndroid() {
if (!adb.getAdbInstance()) return;
try {
await adb.push(
stagedMenuAndroidPath,
JSON.stringify(
{
version: 1,
updatedAt: new Date().toISOString(),
menus: stagedMenus
},
null,
2
)
);
} catch (error) {
console.error('failed to persist staged menus to Android', error);
addNotification('WARN:Failed to save draft menus to Android');
}
}
async function loadStagedMenusFromAndroid() {
if (!adb.getAdbInstance()) return false;
try {
const content = await adb.pull(stagedMenuAndroidPath, 10000);
if (!content || content.trim().length === 0) {
if (stagedMenus.length > 0) {
await persistStagedMenusToAndroid();
}
return false;
}
const parsed = JSON.parse(content);
const menus = Array.isArray(parsed) ? parsed : parsed?.menus;
if (!Array.isArray(menus)) {
addNotification('WARN:Android draft menu file has invalid format');
return false;
}
const deletedCodes = getDeletedStagedMenuCodes();
const filteredMenus = menus.filter((menu) => !deletedCodes.has(String(menu?.productCode)));
stagedMenus = filteredMenus;
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
if (filteredMenus.length !== menus.length) {
await persistStagedMenusToAndroid();
}
buildOverviewForBrewing();
return true;
} catch (error) {
console.error('failed to load staged menus from Android', error);
addNotification('WARN:Failed to load draft menus from Android');
return false;
}
}
function materialDisplayName(material: any) {
const thaiName = material?.materialName ?? '';
const englishName = material?.materialOtherName ?? '';
if (thaiName && englishName) return `${material.id} - ${thaiName} (${englishName})`;
return `${material?.id ?? '-'} - ${thaiName || englishName || 'Unnamed material'}`;
}
function getMaterial(materialPathId: number | null) {
if (materialPathId == null) return undefined;
return (devRecipe?.MaterialSetting ?? []).find(
(material: any) => Number(material?.id) === Number(materialPathId)
);
}
function toppingGroupDisplayName(group: any) {
const groupName = group?.otherName || group?.name || 'Unnamed group';
return `${group?.groupID ?? '-'} - ${groupName}`;
}
function toppingListDisplayName(topping: any) {
const toppingName = topping?.otherName || topping?.name || 'Unnamed topping';
return `${topping?.id ?? '-'} - ${toppingName}`;
}
function getAnyToppingGroup(groupID: number | null) {
if (groupID == null) return undefined;
return (devRecipe?.Topping?.ToppingGroup ?? []).find(
(group: any) => Number(group?.groupID) === Number(groupID)
);
}
function getAnyToppingList(toppingID: number | null) {
if (toppingID == null) return undefined;
return (devRecipe?.Topping?.ToppingList ?? []).find(
(topping: any) => Number(topping?.id) === Number(toppingID)
);
}
async function brewMenuOnAndroid(menu: any) {
if (!(await ensureAndroidSocket())) return;
await sendToAndroid({
type: 'brew_prep',
payload: {
start: new Date().toLocaleTimeString()
}
});
await sendToAndroid({
type: 'brew',
payload: {
start: new Date().toLocaleTimeString(),
target: '-',
data: menu
}
});
addNotification(`INFO:Brew request sent: ${menu.productCode}`);
}
function getRecipeStepValueSummary(step: any) {
const fields = [
['powderGram', 'Powder gram'],
['powderTime', 'Powder time'],
['syrupGram', 'Syrup gram'],
['syrupTime', 'Syrup time'],
['waterCold', 'Water cold'],
['waterYield', 'Water yield'],
['stirTime', 'Stir time']
];
return fields
.map(([key, label]) => ({ label, value: Number(step?.[key] ?? 0) }))
.filter((field) => Number.isFinite(field.value) && field.value !== 0);
}
function getPendingBrewMaterials() {
return (pendingBrewMenu?.recipes ?? [])
.filter((step: any) => {
const materialPathId = Number(step?.materialPathId);
return (
step?.isUse !== false &&
Number.isFinite(materialPathId) &&
!isToppingSlotMaterial(materialPathId)
);
})
.map((step: any, index: number) => {
const materialPathId = Number(step.materialPathId);
const material = getMaterial(materialPathId);
return {
index: index + 1,
materialPathId,
name: material ? materialDisplayName(material) : `${materialPathId} - Unknown material`,
values: getRecipeStepValueSummary(step)
};
});
}
function getPendingBrewToppings() {
return (pendingBrewMenu?.ToppingSet ?? [])
.map((toppingSet: any, index: number) => {
const groupID = Number(toppingSet?.groupID);
const defaultIDSelect = Number(toppingSet?.defaultIDSelect);
if (
toppingSet?.isUse === false ||
!Number.isFinite(groupID) ||
!Number.isFinite(defaultIDSelect) ||
defaultIDSelect <= 0
) {
return null;
}
const group = getAnyToppingGroup(groupID);
const topping = getAnyToppingList(defaultIDSelect);
const slotMaterial = getMaterial(8111 + index);
return {
slot: index + 1,
slotName:
slotMaterial?.materialOtherName ||
slotMaterial?.materialName ||
`Topping slot ${index + 1}`,
groupName: group ? toppingGroupDisplayName(group) : `${groupID} - Unknown group`,
toppingName: topping
? toppingListDisplayName(topping)
: `${defaultIDSelect} - Unknown topping`
};
})
.filter(Boolean);
}
function openBrewConfirm(menu: any) {
pendingBrewMenu = menu;
brewConfirmOpen = true;
}
function promptBrewStagedMenu(menu: any) {
openBrewConfirm(menu);
}
async function confirmBrewNow() {
if (!pendingBrewMenu) return;
await brewMenuOnAndroid(pendingBrewMenu);
brewConfirmOpen = false;
pendingBrewMenu = null;
}
// Track menus pending save verification
let pendingSaveVerification = $state<Set<string>>(new Set());
async function verifyMenuSaved(productCode: string, attempt: number = 1): Promise<boolean> {
const maxAttempts = 3;
const delayMs = 2000; // 2 seconds between attempts
if (!adb.getAdbInstance() || recipeLoading) {
return false;
}
await startFetchRecipeFromMachine();
if (findRecipeByProductCode(productCode)) {
return true;
}
// Retry if not found and attempts remaining
if (attempt < maxAttempts) {
addNotification(`INFO:Retry ${attempt}/${maxAttempts} for ${productCode}...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return verifyMenuSaved(productCode, attempt + 1);
}
return false;
}
function handleMenuSaved(productCode: string) {
markDeletedStagedMenu(productCode);
stagedMenus = stagedMenus.filter((menu) => menu.productCode !== productCode);
persistStagedMenus();
clearMenuSaveState(productCode);
addNotification(`INFO:Menu saved: ${productCode}`);
buildOverviewForBrewing();
}
onMount(() => {
// Set up callback for when menu is saved to Android
setOnMenuSavedCallback(handleMenuSaved);
try {
const stored = localStorage.getItem(stagedMenuStorageKey);
stagedMenus = stored ? JSON.parse(stored) : [];
} catch (error) {
stagedMenus = [];
}
buildOverviewForBrewing();
if (adb.getAdbInstance()) {
void loadStagedMenusFromAndroid();
}
});
onDestroy(() => {
clearOnMenuSavedCallback();
});
$effect(() => {
if (!isAdbConnected) {
recipeAutoLoadAttempted = false;
return;
}
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
recipeAutoLoadAttempted = true;
void loadBrewDataFromConnectedAdb();
});
$effect(() => {
const brewAppStatusInterval = setInterval(async () => {
// schedule status from .brew_web_status.log
let inst = adb.getAdbInstance();
if (inst && devRecipe) {
if (inst && devRecipe && !recipeLoading) {
await adb.executeCmd(
'tail -n 1 /sdcard/coffeevending/.brew_web_status.log > /sdcard/coffeevending/.brew_web_status.latest.log'
);
let brew_status_log = await adb.pull(env.PUBLIC_BREW_WEB_LATEST_STATUS);
const latestStatusPath = env.PUBLIC_BREW_WEB_LATEST_STATUS;
if (!latestStatusPath) return;
let brew_status_log = await adb.pull(latestStatusPath);
if (brew_status_log) {
let latest_log = brew_status_log;
@ -395,16 +791,31 @@
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Brew</h1>
<p class="mx-8 my-0 text-muted-foreground">Brewing directly from web to machine</p>
<!-- <p class="mx-8 my-0 text-muted-foreground">Brewing directly from web to machine</p>
<p class="mx-8 my-0 text-muted-foreground">
Note: refreshing page may cut connection with machine
</p>
</p> -->
</div>
<div class="mx-8 my-4 flex gap-2">
{#if !adb.getAdbInstance() || !devRecipe}
{#if !isAdbConnected}
<Button variant="default" onclick={() => connectAdb()}>Connect</Button>
{:else if !isRecipeLoaded}
<Button
variant="default"
onclick={() => loadBrewDataFromConnectedAdb()}
disabled={recipeLoading}
>
{recipeLoading ? 'Loading...' : 'Load Recipes'}
</Button>
{#if !isAndroidSocketConnected}
<Button variant="outline" onclick={() => adb.reconnectAndroidServer()}
>Reconnect Socket</Button
>
{/if}
{:else}
<Button variant="default">+ Create Menu</Button>
<Button variant="default" onclick={() => goto('/tools/create-menu?open=true')}
>+ Create Menu</Button
>
{/if}
</div>
@ -427,3 +838,107 @@
{/key}
</div>
</div>
<Dialog.Root bind:open={brewConfirmOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Confirm Brew</Dialog.Title>
<Dialog.Description>
Check the material and topping list before sending this menu to Android.
</Dialog.Description>
</Dialog.Header>
{#if pendingBrewMenu}
<div class="grid gap-4 py-2">
<div class="rounded-md border bg-muted/20 p-4">
<div class="text-sm text-muted-foreground">Menu</div>
<div class="mt-1 text-lg font-semibold">
{pendingBrewMenu.name || pendingBrewMenu.otherName || pendingBrewMenu.productCode}
</div>
<div class="mt-1 font-mono text-sm text-muted-foreground">
{pendingBrewMenu.productCode}
</div>
</div>
<div class="rounded-md border p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">Materials</h3>
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
{getPendingBrewMaterials().length} items
</span>
</div>
<div class="grid gap-2">
{#each getPendingBrewMaterials() as material}
<div class="rounded-md border bg-background/70 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs text-muted-foreground">Step {material.index}</div>
<div class="font-medium">{material.name}</div>
</div>
<div class="font-mono text-xs text-muted-foreground">
{material.materialPathId}
</div>
</div>
{#if material.values.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each material.values as field}
<span
class="rounded-full bg-emerald-500 px-2.5 py-1 font-mono text-xs font-semibold text-white"
>
{field.label}: {field.value}
</span>
{/each}
</div>
{:else}
<div class="mt-3 text-sm text-muted-foreground">No amount values set</div>
{/if}
</div>
{/each}
</div>
</div>
<div class="rounded-md border p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">Toppings</h3>
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
{getPendingBrewToppings().length} items
</span>
</div>
{#if getPendingBrewToppings().length === 0}
<div class="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No toppings selected.
</div>
{:else}
<div class="grid gap-2">
{#each getPendingBrewToppings() as topping}
<div class="rounded-md border bg-background/70 p-3">
<div class="text-xs text-muted-foreground">
Slot {topping.slot}: {topping.slotName}
</div>
<div class="mt-1 font-medium">{topping.toppingName}</div>
<div class="mt-1 text-sm text-muted-foreground">{topping.groupName}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<Dialog.Footer>
<Button
variant="outline"
onclick={() => {
brewConfirmOpen = false;
pendingBrewMenu = null;
}}
>
Cancel
</Button>
<Button onclick={confirmBrewNow} disabled={!pendingBrewMenu}>Confirm Brew</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,442 @@
<script lang="ts">
import { auth } from '$lib/core/stores/auth';
import { addNotification } from '$lib/core/stores/noti';
import Button from '$lib/components/ui/button/button.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import Progress from '$lib/components/ui/progress/progress.svelte';
import { Upload, X, ImageIcon, CheckCircle, AlertCircle } from '@lucide/svelte/icons';
const UPLOAD_PROXY_ENDPOINT = '/api/image-upload';
const ALLOWED_FOLDERS = [
{ value: 'page_drink_picture2_n', label: 'page_drink_picture2_n' },
{ value: 'page_drink_n', label: 'page_drink_n' },
{ value: 'page_drink_disable_n2', label: 'page_drink_disable_n2' },
{ value: 'page_drink_press', label: 'page_drink_press' },
// { value: 'page_drink', label: 'page_drink' },
// { value: 'page_drink_disable', label: 'page_drink_disable' },
// { value: 'page_drink_disable_n', label: 'page_drink_disable_n' },
// { value: 'page_drink_press_n', label: 'page_drink_press_n' },
// { value: 'page_drink_select', label: 'page_drink_select' }
];
const COUNTRIES = [
{ value: 'tha', label: 'Thailand (tha)' },
{ value: 'myn', label: 'Myanmar (myn)' },
{ value: 'jpn', label: 'Japan (jpn)' },
{ value: 'chn', label: 'China (chn)' },
{ value: '', label: 'No Country (Global)' }
];
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
interface FileItem {
id: string;
file: File;
preview: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
let selectedCountry = $state('tha');
let selectedFolder = $state('page_drink_picture2_n');
let files = $state<FileItem[]>([]);
let uploading = $state(false);
let uploadProgress = $state({ current: 0, total: 0 });
let dragOver = $state(false);
function generateId() {
return Math.random().toString(36).substring(2, 9);
}
function isValidFile(file: File): boolean {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
addFiles(Array.from(input.files));
}
input.value = '';
}
function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (event.dataTransfer?.files) {
addFiles(Array.from(event.dataTransfer.files));
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
dragOver = true;
}
function handleDragLeave() {
dragOver = false;
}
function addFiles(newFiles: File[]) {
const validFiles = newFiles.filter((file) => {
if (!isValidFile(file)) {
addNotification(`WARN:${file.name} - Invalid file type`);
return false;
}
// Check for duplicates
if (files.some((f) => f.file.name === file.name)) {
addNotification(`WARN:${file.name} - Already added`);
return false;
}
return true;
});
const newItems: FileItem[] = validFiles.map((file) => ({
id: generateId(),
file,
preview: URL.createObjectURL(file),
status: 'pending'
}));
files = [...files, ...newItems];
}
function removeFile(id: string) {
const item = files.find((f) => f.id === id);
if (item) {
URL.revokeObjectURL(item.preview);
}
files = files.filter((f) => f.id !== id);
}
function clearAllFiles() {
files.forEach((f) => URL.revokeObjectURL(f.preview));
files = [];
}
async function uploadFiles() {
const currentUser = $auth;
if (!currentUser) {
addNotification('ERR:Not logged in');
return;
}
const pendingFiles = files.filter((f) => f.status === 'pending' || f.status === 'error');
if (pendingFiles.length === 0) {
addNotification('WARN:No files to upload');
return;
}
uploading = true;
uploadProgress = { current: 0, total: pendingFiles.length };
const uid = currentUser.uid;
const displayName = currentUser.displayName || 'unknown';
const email = currentUser.email || 'unknown@email.com';
for (let i = 0; i < pendingFiles.length; i++) {
const item = pendingFiles[i];
const index = files.findIndex((f) => f.id === item.id);
if (index === -1) continue;
files[index].status = 'uploading';
try {
const formData = new FormData();
formData.append('country', selectedCountry);
formData.append('folder', selectedFolder);
formData.append('uid', uid);
formData.append('displayName', displayName);
formData.append('email', email);
formData.append('file', item.file);
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(errorData.detail || 'Upload failed');
}
files[index].status = 'success';
uploadProgress = { current: i + 1, total: pendingFiles.length };
} catch (error) {
files[index].status = 'error';
files[index].error = error instanceof Error ? error.message : 'Unknown error';
console.error(`Upload error for ${item.file.name}:`, error);
}
}
uploading = false;
const successCount = files.filter((f) => f.status === 'success').length;
const errorCount = files.filter((f) => f.status === 'error').length;
if (errorCount === 0) {
addNotification(`INFO:Uploaded ${successCount} file(s) successfully`);
} else {
addNotification(`WARN:Uploaded ${successCount}, failed ${errorCount}`);
}
}
function getStatusIcon(status: FileItem['status']) {
switch (status) {
case 'success':
return CheckCircle;
case 'error':
return AlertCircle;
default:
return null;
}
}
function getStatusColor(status: FileItem['status']) {
switch (status) {
case 'success':
return 'text-green-500';
case 'error':
return 'text-red-500';
case 'uploading':
return 'text-blue-500';
default:
return 'text-muted-foreground';
}
}
$effect(() => {
return () => {
files.forEach((f) => URL.revokeObjectURL(f.preview));
};
});
</script>
<div class="flex min-h-screen flex-col">
<!-- Header -->
<div class="sticky top-0 z-10 border-b bg-background">
<div class="flex items-center justify-between px-8 py-4">
<div>
<h1 class="text-2xl font-bold">Image Upload</h1>
<p class="text-sm text-muted-foreground">Upload menu images to the server</p>
</div>
<div class="flex items-center gap-4">
{#if files.length > 0}
<Button variant="outline" onclick={clearAllFiles} disabled={uploading}>
<X class="mr-2 h-4 w-4" />
Clear All
</Button>
{/if}
<Button onclick={uploadFiles} disabled={uploading || files.length === 0}>
{#if uploading}
<Spinner class="mr-2 h-4 w-4" />
Uploading {uploadProgress.current}/{uploadProgress.total}...
{:else}
<Upload class="mr-2 h-4 w-4" />
Upload ({files.filter((f) => f.status === 'pending' || f.status === 'error').length})
{/if}
</Button>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-8">
<div class="mx-auto max-w-6xl space-y-6">
<!-- Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Upload Settings</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid gap-6 md:grid-cols-2">
<div class="space-y-2">
<Label>Country</Label>
<Select.Root type="single" bind:value={selectedCountry}>
<Select.Trigger class="w-full">
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES as country}
<Select.Item value={country.value}>{country.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Folder</Label>
<Select.Root type="single" bind:value={selectedFolder}>
<Select.Trigger class="w-full">
{ALLOWED_FOLDERS.find((f) => f.value === selectedFolder)?.label || 'Select folder'}
</Select.Trigger>
<Select.Content>
{#each ALLOWED_FOLDERS as folder}
<Select.Item value={folder.value}>{folder.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="mt-4">
<!-- <p class="text-xs text-muted-foreground">
Endpoint: <code class="rounded bg-muted px-1 py-0.5">
{selectedCountry
? `/inter/${selectedCountry}/image/${selectedFolder}/upload/...`
: `/image/${selectedFolder}/upload/...`}
</code>
</p> -->
</div>
</Card.Content>
</Card.Root>
<!-- Drop Zone -->
<Card.Root>
<Card.Content class="p-6">
<label
class="flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors {dragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
>
<input
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp"
class="hidden"
onchange={handleFileSelect}
disabled={uploading}
/>
<ImageIcon class="mb-4 h-12 w-12 text-muted-foreground" />
<p class="mb-2 text-lg font-medium">Drop images here or click to browse</p>
<p class="text-sm text-muted-foreground">
Supported formats: JPG, JPEG, PNG, GIF, WEBP
</p>
</label>
</Card.Content>
</Card.Root>
<!-- Upload Progress -->
{#if uploading}
<div class="space-y-2">
<Progress
value={uploadProgress.total > 0
? (uploadProgress.current / uploadProgress.total) * 100
: 0}
max={100}
class="h-2"
/>
<p class="text-center text-sm text-muted-foreground">
Uploading: {uploadProgress.current} / {uploadProgress.total}
</p>
</div>
{/if}
<!-- File List -->
{#if files.length > 0}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center justify-between">
<span>Selected Files ({files.length})</span>
<div class="flex gap-2">
{#if files.some((f) => f.status === 'success')}
<Badge variant="default" class="bg-green-500">
{files.filter((f) => f.status === 'success').length} uploaded
</Badge>
{/if}
{#if files.some((f) => f.status === 'error')}
<Badge variant="destructive">
{files.filter((f) => f.status === 'error').length} failed
</Badge>
{/if}
{#if files.some((f) => f.status === 'pending')}
<Badge variant="secondary">
{files.filter((f) => f.status === 'pending').length} pending
</Badge>
{/if}
</div>
</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each files as item (item.id)}
<div
class="group relative overflow-hidden rounded-lg border bg-muted/30 transition-shadow hover:shadow-md"
>
<!-- Preview Image -->
<div class="aspect-square">
<img
src={item.preview}
alt={item.file.name}
class="h-full w-full object-cover"
/>
</div>
<!-- Overlay for status -->
{#if item.status === 'uploading'}
<div
class="absolute inset-0 flex items-center justify-center bg-black/50"
>
<Spinner class="h-8 w-8 text-white" />
</div>
{:else if item.status === 'success'}
<div
class="absolute inset-0 flex items-center justify-center bg-green-500/20"
>
<CheckCircle class="h-10 w-10 text-green-500" />
</div>
{:else if item.status === 'error'}
<div
class="absolute inset-0 flex items-center justify-center bg-red-500/20"
>
<AlertCircle class="h-10 w-10 text-red-500" />
</div>
{/if}
<!-- Remove button -->
{#if item.status !== 'uploading'}
<button
class="absolute top-2 right-2 rounded-full bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => removeFile(item.id)}
disabled={uploading}
>
<X class="h-4 w-4" />
</button>
{/if}
<!-- File info -->
<div class="p-2">
<p
class="truncate text-xs font-medium"
title={item.file.name}
>
{item.file.name}
</p>
<p class="text-xs text-muted-foreground">
{(item.file.size / 1024).toFixed(1)} KB
</p>
{#if item.error}
<p class="truncate text-xs text-red-500" title={item.error}>
{item.error}
</p>
{/if}
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,42 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
// Method 2: forward a machine-generated sync_1.file to the adv FTP server.
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const country = formData.get('country') as string;
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const file = formData.get('file') as File;
if (!country || !uid || !displayName || !email || !file) {
throw error(400, 'Missing required fields');
}
const endpoint = `${ADV_API_BASE}/adv/manifest/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const response = await fetch(endpoint, { method: 'POST', body: uploadFormData });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Manifest upload failed');
}
return json(await response.json());
} catch (err) {
console.error('[Adv Manifest Proxy] Error:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};

View file

@ -0,0 +1,55 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
// Adv videos are served by the same taobin_image service as menu images.
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const country = formData.get('country') as string;
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const file = formData.get('file') as File;
// 'false' when the manifest is generated on a machine (method 2).
const regenerate = (formData.get('regenerate') as string) ?? 'true';
if (!country || !uid || !displayName || !email || !file) {
throw error(400, 'Missing required fields');
}
const endpoint =
`${ADV_API_BASE}/adv/upload/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}` +
`?regenerate=${encodeURIComponent(regenerate)}`;
console.log('[Adv Upload Proxy] Endpoint:', endpoint, 'file:', file.name);
// Upstream expects the multipart field name `files`.
const uploadFormData = new FormData();
uploadFormData.append('files', file);
const response = await fetch(endpoint, {
method: 'POST',
body: uploadFormData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Upload failed');
}
const result = await response.json();
return json(result);
} catch (err) {
console.error('[Adv Upload Proxy] Error:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const IMAGE_API_BASE = env.PUBLIC_POST_IMAGE;
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const country = formData.get('country') as string;
const folder = formData.get('folder') as string;
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const file = formData.get('file') as File;
if (!folder || !uid || !displayName || !email || !file) {
throw error(400, 'Missing required fields');
}
// Build the upload endpoint
let endpoint: string;
if (country) {
endpoint = `${IMAGE_API_BASE}/inter/${encodeURIComponent(country)}/image/${encodeURIComponent(folder)}/upload/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
} else {
endpoint = `${IMAGE_API_BASE}/image/${encodeURIComponent(folder)}/upload/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
}
console.log('[Image Upload Proxy] Endpoint:', endpoint);
// Create new FormData for the upstream request
const uploadFormData = new FormData();
uploadFormData.append('files', file);
const response = await fetch(endpoint, {
method: 'POST',
body: uploadFormData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Upload failed');
}
const result = await response.json();
return json(result);
} catch (err) {
console.error('[Image Upload Proxy] Error:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};

View file

@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
// In-memory store for streamed catalog data
// Format: { batchId: { chunks: [...], status: 'collecting'|'complete'|'error' } }
const streamCache = new Map<string, any>();
export async function POST({ request }) {
try {
const data = await request.json();
const { batch_id, msg, content, current_chunk, total_chunks } = data.payload;
// Initialize or update batch
if (!streamCache.has(batch_id)) {
streamCache.set(batch_id, {
chunks: [],
status: 'collecting',
total_chunks,
createdAt: Date.now()
});
}
const batch = streamCache.get(batch_id);
// Handle different message types
if (msg === 'start') {
console.log(`[API] Stream started for batch ${batch_id}`);
} else if (msg === 'chunk') {
batch.chunks.push(content);
console.log(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
} else if (msg === 'end') {
batch.status = 'complete';
console.log(`[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`);
} else if (msg === 'error') {
batch.status = 'error';
batch.error = content;
console.log(`[API] Stream error for batch ${batch_id}:`, content);
}
return json({ status: 'received', batch_id });
} catch (error) {
console.error('[API] Error processing stream:', error);
return json({ status: 'error', message: String(error) }, { status: 500 });
}
}
export function GET({ url }) {
const batchId = url.searchParams.get('batch_id');
// Clean up old cache entries (older than 5 minutes)
const now = Date.now();
for (const [key, value] of streamCache.entries()) {
if (now - value.createdAt > 5 * 60 * 1000) {
streamCache.delete(key);
}
}
// If batch_id specified, return that specific batch
if (batchId) {
const batch = streamCache.get(batchId);
if (!batch) {
return json({ status: 'not_found' }, { status: 404 });
}
return json({
batch_id: batchId,
status: batch.status,
chunks: batch.chunks,
total_chunks: batch.total_chunks,
error: batch.error || null,
createdAt: new Date(batch.createdAt).toISOString()
});
}
// Otherwise return list of all recent batches
const batches = Array.from(streamCache.entries()).map(([batchId, batch]) => ({
batch_id: batchId,
status: batch.status,
chunks_count: batch.chunks.length,
total_chunks: batch.total_chunks,
error: batch.error || null,
createdAt: new Date(batch.createdAt).toISOString()
}));
return json({
status: 'success',
batches: batches.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
});
}

View file

@ -21,7 +21,7 @@ export default defineConfig({
environment: 'browser',
browser: {
enabled: true,
provider: 'playwright',
//provider: 'playwright',
instances: [{ browser: 'chromium' }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],