Compare commits

..

No commits in common. "7a406fd409bdba96f6a84cb76345347c8bddf864" and "cd88d5aed916215cbb3c1cfc7ca8f7cfbc7f8cc4" have entirely different histories.

48 changed files with 967 additions and 9360 deletions

View file

@ -11,7 +11,6 @@
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
"@tanstack/match-sorter-utils": "^8.19.4",
"@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
"@yume-chan/adb": "^2.6.0",
"@yume-chan/adb-credential-web": "^2.1.0",
@ -23,7 +22,6 @@
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
"semver": "^7.8.4",
"usb": "^2.17.0",
"uuid": "^13.0.0",
"xterm-addon-fit": "^0.8.0",
@ -56,7 +54,7 @@
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"storybook": "^10.3.5",
"svelte": "^5.56.3",
"svelte": "^5.55.2",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
@ -506,8 +504,6 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="],
@ -838,7 +834,7 @@
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
@ -866,7 +862,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
@ -1044,15 +1040,9 @@
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
"svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
"svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
"svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],

BIN
bun.lockb

Binary file not shown.

View file

@ -44,7 +44,7 @@
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"storybook": "^10.3.5",
"svelte": "^5.56.3",
"svelte": "^5.55.2",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
@ -66,7 +66,6 @@
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
"@tanstack/match-sorter-utils": "^8.19.4",
"@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
"@yume-chan/adb": "^2.6.0",
"@yume-chan/adb-credential-web": "^2.1.0",
@ -78,7 +77,6 @@
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
"semver": "^7.8.4",
"usb": "^2.17.0",
"uuid": "^13.0.0",
"xterm-addon-fit": "^0.8.0",

View file

@ -1,174 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { GlobalEventBus } from '$lib/core/utils/eventBus';
import { XIcon } from '@lucide/svelte/icons';
interface AnnouncementPayload {
title?: string;
subtitle?: string;
message: string;
buttonText?: string;
type?: 'info' | 'warning' | 'error' | 'success';
}
let visible = $state(false);
let payload = $state<AnnouncementPayload | null>(null);
let animating = $state(false);
function show(p: AnnouncementPayload) {
payload = p;
visible = true;
// Trigger enter animation on next frame
requestAnimationFrame(() => {
animating = true;
});
}
function dismiss() {
animating = false;
// Wait for exit animation
setTimeout(() => {
visible = false;
payload = null;
}, 200);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
dismiss();
}
}
let unsubscribe: (() => void) | undefined;
onMount(() => {
unsubscribe = GlobalEventBus.on('announce', (data: AnnouncementPayload) => {
show(data);
});
// if (window) window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
unsubscribe?.();
// if (window) window.removeEventListener('keydown', handleKeydown);
});
const typeStyles: Record<string, { border: string; badge: string; icon: string }> = {
info: {
border: 'border-blue-500',
badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
icon: ''
},
warning: {
border: 'border-amber-500',
badge: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
icon: ''
},
error: {
border: 'border-red-500',
badge: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
icon: ''
},
success: {
border: 'border-green-500',
badge: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
icon: ''
}
};
function currentStyles() {
return typeStyles[payload?.type ?? 'info'] ?? typeStyles.info;
}
</script>
{#if visible}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 md:p-8"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
onclick={(e) => {
// Close on backdrop click
if (e.target === e.currentTarget) dismiss();
}}
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/60 backdrop-blur-lg transition-opacity duration-200"
class:opacity-100={animating}
class:opacity-0={!animating}
></div>
<!-- Card -->
<div
class="relative w-full max-w-lg overflow-hidden rounded-2xl border bg-white shadow-2xl transition-all duration-200 dark:border-neutral-700 dark:bg-neutral-900"
class:opacity-100={animating}
class:opacity-0={!animating}
class:scale-100={animating}
class:scale-95={!animating}
>
<!-- Colored top border accent -->
<div class="h-1.5 w-full {currentStyles().border}" />
<div class="p-6 sm:p-8">
<!-- Close button -->
<button
onclick={dismiss}
class="absolute top-4 right-4 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
aria-label="Close announcement"
>
<XIcon size={20} />
</button>
<!-- Type badge -->
{#if payload?.type}
<span
class="mb-4 inline-block rounded-full px-3 py-1 text-xs font-semibold tracking-wider uppercase {currentStyles()
.badge}"
>
{payload.type}
</span>
{/if}
<!-- Title -->
{#if payload?.title}
<h2
id="announcement-title"
class="pr-8 text-2xl font-bold text-neutral-900 dark:text-white"
>
{payload.title}
</h2>
{/if}
<!-- Subtitle -->
{#if payload?.subtitle}
<p class="mt-1 text-sm font-medium text-neutral-500 dark:text-neutral-400">
{payload.subtitle}
</p>
{/if}
<!-- Message body -->
{#if payload?.message}
<div
class="mt-4 text-base leading-relaxed whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"
>
{payload.message}
</div>
{/if}
<!-- Acknowledge / action button -->
{#if payload?.buttonText !== undefined}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={dismiss}
class="inline-flex items-center justify-center rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-neutral-800 focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:outline-none dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
>
{payload.buttonText || 'Acknowledge'}
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -15,7 +15,6 @@
import { browser } from '$app/environment';
import { deleteCookiesOnNonBrowser } from '$lib/helpers/cookie';
import { socketStore } from '$lib/core/stores/websocketStore';
import { GlobalEventBus } from '$lib/core/utils/eventBus';
const sidebar = useSidebar();
@ -38,7 +37,7 @@
if (instance) {
try {
await adb.executeCmd('rm /sdcard/coffeevending/ignore_pass');
// await adb.executeCmd('reboot');
await adb.executeCmd('reboot');
await adb.disconnect();
} catch (e) {
console.error('error disconnect device while logging out', e);
@ -46,7 +45,6 @@
}
authStore.set(null);
GlobalEventBus.clear();
let socket = get(socketStore);

View file

@ -17,7 +17,6 @@
PlusCircle,
ImageUp,
Video,
MonitorPlay,
Sun,
Moon
} from '@lucide/svelte/icons';
@ -126,12 +125,6 @@
url: '/tools/adv-upload',
icon: Video,
requirePerm: ''
},
{
title: 'Main & Brewing Video',
url: '/tools/video-mainpage',
icon: MonitorPlay,
requirePerm: ''
}
]
},
@ -149,12 +142,6 @@
url: '/departments',
icon: DollarSign,
requirePerm: 'document.write.*'
},
{
title: 'Price',
url: '/departments',
icon: FileSpreadsheet,
requirePerm: 'document.write.*'
}
]
}
@ -248,13 +235,7 @@
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
referenceFromPage.set(
sub.title === 'PriceSlot'
? 'priceslot'
: sub.title === 'Price'
? 'price'
: 'sheet'
);
referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
goto(sub.url);
}
}}

View file

@ -83,12 +83,12 @@
errors: []
});
// handleIncomingMessages(
// JSON.stringify({
// type: 'chat',
// payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
// })
// );
handleIncomingMessages(
JSON.stringify({
type: 'chat',
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
})
);
} else {
machineStatus = 'Instance lost, try disconnect and re-connect again';
toast.error('Unexpected Error');
@ -221,12 +221,12 @@
connectDeviceOk = false;
// handleIncomingMessages(
// JSON.stringify({
// type: 'chat',
// payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has disconnected!`
// })
// );
handleIncomingMessages(
JSON.stringify({
type: 'chat',
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has disconnected!`
})
);
}
function checkDeviceConnection() {

View file

@ -183,9 +183,9 @@
}
}
async function saveSheetPrice() {
function saveSheetPrice() {
if (!canEditSheetPrice || sheetPriceValue === null) return;
await sendCommandRequest('sheet', {
sendCommandRequest('sheet', {
country: get(departmentStore),
content: [
{

View file

@ -11,7 +11,7 @@ enum ValueEvent {
SAVED
}
async function actionReport(action_name: string, values: any, currentRef: string) {
function actionReport(action_name: string, values: any, currentRef: string) {
let country = get(departmentStore) ?? 'unknown dep';
if (currentRef === 'brew') {
@ -27,7 +27,7 @@ async function actionReport(action_name: string, values: any, currentRef: string
}
}
await sendMessage({
sendMessage({
type: 'log_report',
payload: {
user: get(auth)?.email ?? 'unknown',

View file

@ -185,7 +185,7 @@
let formatted = formatCustomDate(date);
ready_to_send_brew[0].LastChange = formatted;
await sendMessage({
sendMessage({
type: 'save_recipe',
payload: {
user_info,
@ -194,7 +194,7 @@
}
});
} else if (get(referenceFromPage) == 'overview') {
await sendMessage({
sendMessage({
type: 'save_recipe',
payload: {
user_info,
@ -362,11 +362,11 @@
updateMachineStatus('');
}
// console.log(
// 'machine status pinging recipe editor dialog',
// getMachineStatus(),
// $machineInfoStore?.status
// );
console.log(
'machine status pinging recipe editor dialog',
getMachineStatus(),
$machineInfoStore?.status
);
// update machine status
// check-connection

View file

@ -1,705 +0,0 @@
<script lang="ts">
import '@xterm/xterm/css/xterm.css';
import { Xterm } from '@battlefieldduck/xterm-svelte';
import { FitAddon } from 'xterm-addon-fit';
import type { Terminal } from '@xterm/xterm';
import { onMount, onDestroy } from 'svelte';
import { get } from 'svelte/store';
import {
terminalDrawerOpen,
toggleTerminalDrawer,
closeTerminalDrawer
} from '$lib/core/stores/terminalDrawer';
import { getAdbInstance } from '$lib/core/adb/adb';
import {
initTerminalSession,
reinitTerminalSession,
handleTerminalData,
cleanupTerminalSession,
onTerminalMinimized,
onCommandOutput,
onExpandOutputRequest,
getCommandOutputs,
getLastOutputId,
requestExpandOutput,
type CommandOutput
} from '$lib/core/adb/adbTerminal';
import { auth } from '$lib/core/stores/auth';
import { permission as permissionStore } from '$lib/core/stores/permissions';
import { getUserByUid } from '$lib/core/admin/adminService';
import type { AdminUser } from '$lib/core/admin/adminTypes';
import {
TerminalIcon,
XIcon,
Minimize2Icon,
Maximize2Icon,
ShieldAlertIcon,
LockIcon,
ClockIcon
} from '@lucide/svelte/icons';
import { sendCommandRequest, sendMessage } from '$lib/core/handlers/ws_messageSender';
import { addNotification } from '$lib/core/stores/noti';
let xtermElement = $state<Terminal | undefined>(undefined);
let fitAddon: FitAddon | undefined = $state(undefined);
let isOpen = $state(false);
let isFullScreen = $state(false);
let userRole = $state<string>('');
let isAdminMode = $state(false);
let isCheckingAdmin = $state(true);
let connStatus = $state<'connected' | 'disconnected'>('disconnected');
let expandedOutput = $state<CommandOutput | null>(null);
let historyOpen = $state(false);
let selectedHistoryOutput = $state<CommandOutput | null>(null);
let uploadingId = $state<number | null>(null);
let outputBlocksEnabled = $state(true);
// Check permission on mount
onMount(() => {
// Watch auth for admin status
const unsub = auth.subscribe(async (user) => {
if (user) {
try {
const adminUser = await getUserByUid(user.uid);
userRole = adminUser?.role ?? '';
isAdminMode = adminUser?.role === 'admin';
} catch {
userRole = '';
isAdminMode = false;
}
} else {
userRole = '';
isAdminMode = false;
}
isCheckingAdmin = false;
});
// Watch terminal drawer state
const unsubDrawer = terminalDrawerOpen.subscribe((open) => {
const wasClosed = isOpen && !open;
isOpen = open;
// Just opened — fit the terminal to new container dimensions
if (open && fitAddon) {
// Wait for layout to settle after display:flex kicks in
requestAnimationFrame(() => {
try {
fitAddon?.fit();
} catch {
// fit may fail if container isn't visible yet
}
});
}
// Just closed (minimized) — save state
if (wasClosed) {
onTerminalMinimized();
}
});
// Listen for expand-output requests from terminal link clicks
const unsubExpand = onExpandOutputRequest((id: number) => {
const outputs = getCommandOutputs();
const output = outputs.find((o) => o.id === id);
if (output) {
expandedOutput = output;
}
});
// Keyboard shortcut: Ctrl+` (backtick) to toggle
function handleKeydown(e: KeyboardEvent) {
// Ctrl+` (backtick) to toggle
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
toggleTerminalDrawer();
}
// Ctrl+Shift+` for fullscreen
if (e.ctrlKey && e.shiftKey && e.key === '`') {
e.preventDefault();
if (isOpen) {
isFullScreen = !isFullScreen;
}
}
// Escape to close expanded output dialog
if (e.key === 'Escape' && expandedOutput) {
expandedOutput = null;
}
// Escape to close history dialog
if (e.key === 'Escape' && historyOpen) {
historyOpen = false;
}
}
window.addEventListener('keydown', handleKeydown);
return () => {
unsub();
unsubDrawer();
unsubExpand();
window.removeEventListener('keydown', handleKeydown);
};
});
onDestroy(() => {
cleanupTerminalSession();
});
function onTerminalLoad(terminal: Terminal) {
xtermElement = terminal;
// Add FitAddon for responsive sizing
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
// Handle input — onData is the only way xterm.js reports user keystrokes.
// This must be registered BEFORE initTerminalSession so no keystrokes are lost.
// NOTE: All terminal visual options (theme, font, cursor, etc.) are passed
// via the <Xterm options={...}> prop and set at construction time.
// DO NOT assign terminal.options here — xterm.js throws for readonly
// constructor-only options like 'cols' and 'rows'.
terminal.onData((data: string) => {
if (!isAdminMode) return; // Block input if not admin
handleTerminalData(data);
});
// Register link provider for "click to expand" markers in output
// This makes the "[⤵ N more lines]" text clickable to open the output dialog.
//
// IMPORTANT: xterm.js link ranges use CELL positions (1-based columns),
// NOT JavaScript character indices. Unicode characters like ⤵ (U+2935)
// may occupy 2 cells (double-width). Using string indexOf() for
// positioning would be off by 1 for such characters — the link range
// would not match the mouse hover position and the click would never fire.
//
// Fix: iterate xterm cells directly to find the correct cell position
// of the expand marker, skipping empty trailing cells of double-wide chars.
terminal.registerLinkProvider({
provideLinks(lineNumber: number, callback: (links: any[]) => void) {
const line = terminal.buffer.active.getLine(lineNumber);
if (!line) {
callback([]);
return;
}
// Build line text AND find the cell position of the ⤵ marker
// in a single pass. We iterate xterm cells (not string chars)
// so double-width characters are handled correctly.
let lineText = '';
let foundCellPos = -1;
for (let i = 0; i < line.length; i++) {
const cell = line.getCell(i);
if (!cell) continue;
const chars = cell.getChars();
if (!chars) continue; // skip trailing cell of double-width char
lineText += chars;
if (foundCellPos < 0 && chars.includes('⤵')) {
foundCellPos = i;
}
}
if (foundCellPos >= 0 && lineText.includes('click to expand')) {
const link = {
range: {
// foundCellPos is 0-based cell index → +1 for 1-based column
start: { x: foundCellPos + 1, y: lineNumber + 1 },
// line.length is the cell count → last cell index is length-1,
// so 1-based last column = line.length (inclusive end)
end: { x: line.length, y: lineNumber + 1 }
},
text: lineText,
activate(_event: MouseEvent, _uri: string) {
const lastId = getLastOutputId();
if (lastId > 0) {
requestExpandOutput(lastId);
}
}
};
callback([link]);
return;
}
callback([]);
}
});
// Initialize session (writes welcome banner, sets up prompt)
initTerminalSession(terminal);
}
async function handleUpload(output: CommandOutput | null) {
if (!output) return;
uploadingId = output.id;
const success = await sendCommandRequest('upload-log', {
command: output.command,
output: output.fullOutput,
lines: output.lines,
timestamp: output.timestamp,
message: 'run command',
log_level: output.level
});
uploadingId = null;
if (success) {
addNotification('INFO:Command output uploaded');
} else {
addNotification('ERR:Failed to upload — WebSocket not connected');
}
}
function handleToggle() {
toggleTerminalDrawer();
}
function handleClose() {
onTerminalMinimized();
closeTerminalDrawer();
}
function handleMinimize() {
onTerminalMinimized();
closeTerminalDrawer();
}
function toggleFullScreen() {
isFullScreen = !isFullScreen;
// Re-fit after fullscreen toggle
if (fitAddon && isOpen) {
requestAnimationFrame(() => {
try {
fitAddon?.fit();
} catch {
// ignore
}
});
}
}
// Check connection status periodically
let _connInterval: ReturnType<typeof setInterval> | undefined;
onMount(() => {
_connInterval = setInterval(() => {
const instance = getAdbInstance();
connStatus = instance ? 'connected' : 'disconnected';
}, 2000);
});
onDestroy(() => {
if (_connInterval) clearInterval(_connInterval);
});
</script>
<!-- Floating Terminal Button - Always visible -->
<button
onclick={handleToggle}
class="fixed right-4 bottom-4 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-[#0d1117] text-green-400 shadow-lg ring-1 ring-gray-700 transition-all hover:bg-[#161b22] hover:ring-green-500"
title={isOpen ? 'Close Terminal (Ctrl+`)' : 'Open Terminal (Ctrl+`)'}
>
{#if isOpen}
<XIcon size={18} />
{:else}
<TerminalIcon size={18} />
{/if}
</button>
<!-- Overlay - Only when open -->
{#if isOpen}
<div
class="fixed inset-0 z-40 bg-black/50 transition-opacity"
onclick={handleClose}
role="presentation"
></div>
{/if}
<!-- Drawer Panel - Always mounted, hidden via CSS when minimized -->
<!-- This keeps the Xterm instance alive so terminal buffer is preserved -->
<div
class="fixed bottom-0 z-50 flex flex-col border-t border-gray-700 bg-[#0d1117] shadow-2xl transition-all duration-300 ease-in-out"
class:right-0={!isFullScreen}
class:inset-x-0={!isFullScreen}
class:inset-0={isFullScreen}
style="display: {isOpen ? 'flex' : 'none'}; height: {isFullScreen
? '100%'
: '40vh'}; min-height: {isFullScreen ? '100%' : '250px'}; max-height: {isFullScreen
? '100%'
: '70vh'};"
role="dialog"
aria-label="ADB Terminal"
>
<!-- Title Bar -->
<div
class="flex shrink-0 cursor-default items-center justify-between border-b border-gray-700 bg-[#161b22] px-4 py-2 select-none"
>
<div class="flex items-center gap-3">
<!-- Traffic light dots -->
<div class="flex items-center gap-1.5">
<div
class="h-3 w-3 rounded-full bg-red-500"
onclick={handleClose}
onkeydown={(e) => e.key === 'Enter' && handleClose()}
role="button"
tabindex="0"
aria-label="Close"
></div>
<div
class="h-3 w-3 rounded-full bg-yellow-500"
onclick={handleMinimize}
onkeydown={(e) => e.key === 'Enter' && handleMinimize()}
role="button"
tabindex="0"
aria-label="Minimize"
></div>
<div
class="h-3 w-3 rounded-full bg-green-500"
onclick={toggleFullScreen}
onkeydown={(e) => e.key === 'Enter' && toggleFullScreen()}
role="button"
tabindex="0"
aria-label="Toggle fullscreen"
></div>
</div>
<span class="ml-2 text-sm font-semibold text-gray-300">
<TerminalIcon class="mr-1 inline-block align-text-bottom" size={14} />
ADB Terminal
</span>
<!-- Connection status badge -->
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium {connStatus ===
'connected'
? 'bg-green-900/50 text-green-400'
: 'bg-red-900/50 text-red-400'}"
>
<span
class="h-1.5 w-1.5 rounded-full"
class:bg-green-400={connStatus === 'connected'}
class:bg-red-400={connStatus === 'disconnected'}
></span>
{connStatus === 'connected' ? 'Device Connected' : 'Disconnected'}
</span>
</div>
<div class="flex items-center gap-2">
<!-- Admin badge -->
{#if isCheckingAdmin}
<span class="text-xs text-gray-500">Checking permissions...</span>
{:else if isAdminMode}
<span
class="inline-flex items-center gap-1 rounded bg-yellow-900/30 px-2 py-0.5 text-xs font-medium text-yellow-400"
>
<ShieldAlertIcon size={12} />
Admin
</span>
{:else}
<span
class="inline-flex items-center gap-1 rounded bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-500"
>
<LockIcon size={12} />
Read Only
</span>
{/if}
<!-- History button -->
<button
onclick={() => (historyOpen = !historyOpen)}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Command History"
>
<ClockIcon size={14} />
</button>
<!-- Fullscreen button -->
<button
onclick={toggleFullScreen}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}
>
{#if isFullScreen}
<Minimize2Icon size={14} />
{:else}
<Maximize2Icon size={14} />
{/if}
</button>
</div>
</div>
<!-- Terminal Content -->
<div class="relative flex min-h-0 flex-1">
{#if !isAdminMode && !isCheckingAdmin}
<!-- Read-only overlay for non-admin users -->
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0d1117]/90">
<LockIcon class="mb-2 text-gray-600" size={32} />
<p class="text-sm text-gray-500">Admin permissions required</p>
<p class="mt-1 text-xs text-gray-600">Terminal commands are restricted to admin users</p>
</div>
{/if}
<div class="h-full w-full overflow-hidden">
<Xterm
bind:terminal={xtermElement}
options={{
cursorBlink: true,
cursorStyle: 'block',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
fontSize: 13,
lineHeight: 1.2,
theme: {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground: '#264f78',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc'
}
}}
onLoad={onTerminalLoad}
class="h-full w-full"
style="height:100%; width:100%;"
/>
</div>
</div>
</div>
<!-- Full Output Dialog -->
{#if expandedOutput}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onclick={() => (expandedOutput = null)}
role="presentation"
>
<!-- Dialog -->
<div
class="mx-4 max-h-[80vh] w-full max-w-3xl overflow-hidden rounded-lg border border-gray-700 bg-[#0d1117] shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command full output"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-700 bg-[#161b22] px-4 py-3"
>
<div class="flex items-center gap-2 overflow-hidden">
<span class="shrink-0 text-xs font-medium text-gray-400">Output for:</span>
<code class="truncate text-sm font-semibold text-green-400"
>$ {expandedOutput.command}</code
>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500"
>{expandedOutput.lines} line{expandedOutput.lines !== 1 ? 's' : ''}</span
>
<button
onclick={() => (expandedOutput = null)}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Close"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg
>
</button>
</div>
</div>
<!-- Output Content -->
<div class="overflow-auto p-4" style="max-height: calc(80vh - 52px);">
{#if expandedOutput.fullOutput}
<pre
class="font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-300">{expandedOutput.fullOutput}</pre>
{:else}
<p class="text-sm text-gray-500 italic">(no output)</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- History Dialog -->
{#if historyOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/70 pt-12"
onclick={() => {
historyOpen = false;
selectedHistoryOutput = null;
}}
role="presentation"
>
<!-- Dialog: side-by-side split layout -->
<div
class="mx-4 flex w-full max-w-5xl overflow-hidden rounded-lg border border-gray-700 bg-[#0d1117] shadow-2xl"
style="height: 70vh;"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command history"
>
<!-- Left Panel: List of commands (40%) -->
<div class="flex w-2/5 shrink-0 flex-col border-r border-gray-700" style="min-width: 220px;">
<!-- Header -->
<div
class="flex shrink-0 items-center justify-between border-b border-gray-700 bg-[#161b22] px-3 py-2.5"
>
<div class="flex items-center gap-2">
<ClockIcon size={15} class="text-gray-400" />
<span class="text-sm font-semibold text-gray-200">History</span>
<span class="text-xs text-gray-500">(10)</span>
</div>
</div>
<!-- Scrollable list -->
<div class="overflow-y-auto">
{#each getCommandOutputs().slice(-10).reverse() as output}
<button
onclick={() => {
selectedHistoryOutput = output;
}}
class="flex w-full items-start gap-2.5 border-b border-gray-800 px-3 py-2.5 text-left transition-colors
{selectedHistoryOutput?.id === output.id ? 'bg-blue-900/20' : 'hover:bg-[#161b22]'}"
>
<!-- Level dot -->
<div class="mt-1 shrink-0">
{#if output.level === 'error'}
<div class="h-2 w-2 rounded-full bg-red-500"></div>
{:else if output.level === 'success'}
<div class="h-2 w-2 rounded-full bg-green-500"></div>
{:else}
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
{/if}
</div>
<!-- Command info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<code class="truncate text-xs font-medium text-green-400">$ {output.command}</code
>
<span class="shrink-0 text-[11px] text-gray-500">{output.lines}</span>
</div>
<div class="mt-0.5 truncate text-[11px] text-gray-600">
{output.fullOutput
? output.fullOutput.split('\n')[0].slice(0, 80)
: '(no output)'}
</div>
<div class="mt-0.5 text-[10px] text-gray-700">
{new Date(output.timestamp).toLocaleTimeString()}
</div>
</div>
</button>
{:else}
<div class="flex flex-col items-center justify-center py-10 text-gray-500">
<ClockIcon size={24} class="mb-2 text-gray-700" />
<p class="text-xs">No command history yet</p>
</div>
{/each}
</div>
</div>
<!-- Right Panel: Output viewer (60%) -->
<div class="flex w-3/5 flex-col overflow-hidden">
<!-- Header with controls -->
<div
class="flex shrink-0 items-center justify-between border-b border-gray-700 bg-[#161b22] px-3 py-2.5"
>
<div class="flex items-center gap-2 overflow-hidden">
{#if selectedHistoryOutput}
<span class="shrink-0 text-xs font-medium text-gray-400">Output for:</span>
<code class="truncate text-sm font-semibold text-green-400"
>$ {selectedHistoryOutput.command}</code
>
<span class="shrink-0 text-xs text-gray-500"
>{selectedHistoryOutput.lines} line{selectedHistoryOutput.lines !== 1
? 's'
: ''}</span
>
{:else}
<span class="text-xs text-gray-500">Select a command to view output</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if selectedHistoryOutput}
<button
onclick={() => handleUpload(selectedHistoryOutput)}
disabled={uploadingId === selectedHistoryOutput.id}
class="rounded px-2 py-1 text-xs font-medium transition-colors
{uploadingId === selectedHistoryOutput.id
? 'cursor-not-allowed bg-blue-900/30 text-blue-500'
: 'bg-gray-800 text-gray-400 hover:bg-blue-900/50 hover:text-blue-400'}"
>
{uploadingId === selectedHistoryOutput.id ? 'Uploading...' : 'Upload'}
</button>
{/if}
<button
onclick={() => {
historyOpen = false;
selectedHistoryOutput = null;
}}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Close"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg
>
</button>
</div>
</div>
<!-- Output content -->
<div class="flex-1 overflow-auto">
{#if selectedHistoryOutput}
{#if selectedHistoryOutput.fullOutput}
<pre
class="p-4 font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-300">{selectedHistoryOutput.fullOutput}</pre>
{:else}
<div class="flex items-center justify-center py-16 text-sm text-gray-500 italic">
(no output)
</div>
{/if}
{:else}
<div class="flex h-full flex-col items-center justify-center text-gray-600">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="mb-3 text-gray-700"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline
points="14 2 14 8 20 8"
/><line x1="16" y1="13" x2="8" y2="13" /><line
x1="16"
y1="17"
x2="8"
y2="17"
/><polyline points="10 9 9 9 8 9" />
</svg>
<p class="text-sm">Select a command from the left panel</p>
<p class="mt-1 text-xs text-gray-700">to view its full output here</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}

View file

@ -11,7 +11,6 @@ import { Consumable, MaybeConsumable, ReadableStream } from '@yume-chan/stream-e
import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
import { addNotification } from '../stores/noti';
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
import { GlobalEventBus } from '../utils/eventBus';
import { adbWriter } from '../stores/adbWriter';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
@ -138,7 +137,7 @@ async function connectWithRetry<T>(
export async function connnectViaWebUSB(connectAndroidServer = true) {
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
console.log('usb ok', globalThis.navigator.usb);
if (device) {
console.log('connect ', device.name);
@ -363,92 +362,6 @@ export async function executeCmd(command: string) {
}
}
export async function goToMachineHome() {
if (!getAdbInstance()) return;
try {
await executeCmd('input keyevent KEYCODE_HOME');
} catch (e) {
console.error('[goToMachineHome] error', e);
}
}
/**
* Execute an ADB command and stream its output via callbacks.
* Used for commands like `logcat` that run indefinitely.
*
* Returns a cleanup function that can be called to abort the stream.
*/
export async function executeStreamingCmd(
command: string,
callbacks: {
onData?: (chunk: string) => void;
onError?: (error: string) => void;
onExit?: (exitCode: number | undefined) => void;
}
): Promise<() => void> {
const instance = getAdbInstance();
let aborted = false;
if (!instance) {
callbacks.onError?.('No ADB device connected');
return () => {};
}
try {
// NOTE: Always use noneProtocol.spawn() for streaming.
// shellProtocol.spawnWaitText() waits for the process to exit — fine for
// batch commands like 'echo foo', but logcat runs indefinitely, so the
// Promise would never resolve and onData would never fire.
const process = await instance.subprocess.noneProtocol.spawn(command);
const reader = process.output.getReader();
const decoder = new TextDecoder();
// Start reading in the background
(async () => {
try {
while (!aborted) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text && !aborted) {
callbacks.onData?.(text);
}
}
} catch (e: any) {
if (!aborted) {
callbacks.onError?.(e.message ?? 'Stream read error');
}
} finally {
if (!aborted) {
reader.releaseLock();
callbacks.onExit?.(0);
}
}
})();
// Return cleanup function to abort the stream
return () => {
aborted = true;
try {
reader.cancel();
} catch {
// reader may already be done
}
};
} catch (e: any) {
if (!aborted) {
if (e.message?.includes('ExactReadable ended')) {
callbacks.onError?.('Connection closed');
} else {
callbacks.onError?.(e.message ?? 'Unknown error');
}
}
}
return () => {};
}
export async function disconnect() {
let instance = getAdbInstance();
if (instance) {
@ -613,32 +526,12 @@ async function connectToAndroidServer(maxRetries = 5) {
if (writer) {
addNotification('INFO:Enable Brewing Mode T on machine');
const textDecoder = new TextDecoder();
let buffer = '';
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
// decode chunk
buffer += textDecoder.decode(value, { stream: true });
let lines = buffer.split('\n');
// save potential incomplete
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim() === '') continue;
GlobalEventBus.emit('adb:raw-payload', line);
handleAdbPayload(line);
}
// GlobalEventBus.emit('adb:raw-payload', new TextDecoder().decode(value));
// handleAdbPayload(new TextDecoder().decode(value));
handleAdbPayload(new TextDecoder().decode(value));
}
} catch (e) {
console.error('read error', e);
@ -744,7 +637,6 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
const trimmedMessage = message.trim();
if (trimmedMessage) {
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
GlobalEventBus.emit('adb:raw-payload', trimmedMessage);
handleAdbPayload(trimmedMessage);
}
}
@ -752,7 +644,6 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
const remainingMessage = messageBuffer.trim();
if (remainingMessage) {
GlobalEventBus.emit('adb:raw-payload', remainingMessage);
handleAdbPayload(remainingMessage);
}
} catch (e) {

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@ export async function getRecipes() {
recipeData.set([]);
recipeOverviewData.set([]);
await sendMessage({
sendMessage({
type: 'recipe',
payload: {
auth: idToken ?? '',
@ -82,7 +82,7 @@ export async function getRecipeWithVersion(version: string) {
// NOTE: although version is provided, actual version field is still need to be latest
// Just in case version is not found
await sendMessage({
sendMessage({
type: 'recipe',
payload: {
auth: idToken ?? '',

View file

@ -7,40 +7,16 @@ import {
} from '../services/androidRecipeExportService';
import { handleIncomingMessages } from './messageHandler';
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
import { recipeFromMachine, recipeFromMachineQuery } from '../stores/recipeStore';
import { buildTags, getMenuStatus } from '$lib/data/recipeService';
import * as semver from 'semver';
import { env } from '$env/dynamic/public';
import { getContext } from 'svelte';
import { GlobalEventBus, useEventBus } from '../utils/eventBus';
import { recipeFromMachineQuery } from '../stores/recipeStore';
type AdbPayload = { type: string; payload: any };
let queuedPromises = new Array<Promise<void>>();
async function handleAdbPayload(raw_payload: string) {
// console.log('[ADB] Received payload:', raw_payload.slice(0, 300));
const APP_VERSION = env.PUBLIC_APP_SEMVER;
// const bus = useEventBus();
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);
// Emit payload event for terminal drawer payload viewer
GlobalEventBus.emit('adb:payload', payload);
let payload_type = payload.type;
let sub_type = null;
// sub type handler
if (payload_type.includes('/')) {
//
let tspl = payload_type.split('/');
payload_type = tspl[0];
sub_type = tspl[1];
}
switch (payload_type) {
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'] ?? '';
@ -83,16 +59,16 @@ async function handleAdbPayload(raw_payload: string) {
}
if (raw_payload.startsWith('save_recipe_machine')) {
// handleIncomingMessages(
// JSON.stringify({
// type: 'ui_action',
// payload: {
// action: uiAction,
// from: 'brew',
// ref: `${pd}.${action}`
// }
// })
// );
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('/');
@ -186,177 +162,10 @@ async function handleAdbPayload(raw_payload: string) {
addNotification(`ERR:${error?.message ?? 'Unable to load recipe export from Android'}`)
);
break;
case 'get-recipe-response':
// only update recipe from memory of brew app
if (!semver.satisfies(APP_VERSION, '^0.0.3')) {
// reject
console.log('unsupported version');
break;
}
let stream_recipe_idx = -1;
try {
stream_recipe_idx = parseInt(sub_type ?? '-1');
} catch (_) {}
if (stream_recipe_idx > -1) {
// TODO: update both recipeFromMachine and recipeFromMachineQuery
//
queuedPromises.push(
new Promise((resolve, reject) => {
let recipeRawFromMachine = get(recipeFromMachine);
let recipeMachineQ = get(recipeFromMachineQuery);
if (!Object.keys(recipeMachineQ).includes('recipe')) {
recipeMachineQ = {
...recipeMachineQ,
recipe: {}
};
}
let current_r01_query = recipeMachineQ.recipe;
let rp_from_payload = JSON.parse(payload.payload);
try {
if (
recipeRawFromMachine != null &&
Object.keys(recipeRawFromMachine).includes('Recipe01')
) {
let is_update = false;
// update, assume that we already fetch
for (let rp of recipeRawFromMachine['Recipe01']) {
if (rp['productCode'] == rp_from_payload['productCode']) {
rp = rp_from_payload;
is_update = true;
break;
}
}
// new menu
if (!is_update) recipeRawFromMachine['Recipe01'].push(rp_from_payload);
} else {
// not initialize
recipeRawFromMachine = {
Recipe01: [rp_from_payload]
};
}
// build as overview style compatible
let overview_menu = {
productCode: rp_from_payload['productCode'] ?? '<not set>',
name: rp_from_payload['name']
? rp_from_payload['name']
: (rp_from_payload['otherName'] ?? '<not set>'),
description: rp_from_payload['desciption']
? rp_from_payload['desciption']
: (rp_from_payload['otherDescription'] ?? '<not set>'),
tags: buildTags(rp_from_payload),
status: getMenuStatus(rp_from_payload['MenuStatus'])
};
current_r01_query[overview_menu.productCode] = overview_menu;
recipeMachineQ = {
...recipeMachineQ,
recipe: current_r01_query
};
recipeFromMachineQuery.set(recipeMachineQ);
recipeFromMachine.set(recipeRawFromMachine);
if (stream_recipe_idx == 0) {
addNotification(`INFO:Loading recipes ...`);
}
resolve();
} catch (e) {
reject(e);
}
})
);
} else if (sub_type == 'end' && payload.payload.includes('finish')) {
let force_reload = payload.payload.includes('reload');
console.log('queued recipes: ', queuedPromises.length);
if (queuedPromises.length > 0) {
try {
await Promise.all(queuedPromises);
console.log('clear all recipe promises');
queuedPromises = new Array();
GlobalEventBus.emitUntilConsumed('recipe-event', {
type: 'load-recipe',
status: 'end',
reload: force_reload
});
} catch (e) {
console.error('some promise failed: ', e);
GlobalEventBus.emitUntilConsumed('recipe-event', {
type: 'load-recipe-fail',
status: 'end',
reload: force_reload
});
}
}
} else if (sub_type?.startsWith('mat')) {
let recipeRawFromMachine = get(recipeFromMachine);
if (sub_type == 'mat-0') {
addNotification('INFO:Loading materials ...');
}
if (
recipeRawFromMachine != null &&
Object.keys(recipeRawFromMachine).includes('MaterialSetting')
) {
recipeRawFromMachine.MaterialSetting.push(JSON.parse(payload.payload));
} else {
// not has field material yet
recipeRawFromMachine = {
...recipeRawFromMachine,
MaterialSetting: [JSON.parse(payload.payload)]
};
}
console.log('checking add mat', recipeRawFromMachine);
recipeFromMachine.set(recipeRawFromMachine);
} else if (sub_type == 'toppings') {
let recipeRawFromMachine = get(recipeFromMachine);
let topping_raw = JSON.parse(payload.payload);
console.log('receive topping', topping_raw);
//
recipeRawFromMachine = {
...recipeRawFromMachine,
Topping: topping_raw
};
// materialFromMachineQuery
addNotification('INFO:Loading toppings ...');
recipeFromMachine.set(recipeRawFromMachine);
} else {
// unhandled sub type
console.log('unhandled sub type', payload);
}
break;
default:
}
} catch (error: any) {
// invalid format — emit error event so listeners (e.g. terminal) can react
GlobalEventBus.emit('adb:payload-error', {
raw_payload,
error: error?.message ?? 'Unknown parse error',
timestamp: new Date().toISOString()
});
// invalid format
}
}

View file

@ -21,14 +21,11 @@ import {
handleSheetStreamEnd,
handleSheetStreamError,
handleCatalogsResponse,
handlePriceSlotsResponse,
isPriceSlotsPayload,
handleListMenuResponse,
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
handleRawStreamEnd,
handleSheetPriceResponse
handleRawStreamEnd
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
@ -40,24 +37,15 @@ import { buildOverviewFromServer } from '$lib/data/recipeService';
import { auth } from '../client/firebase';
import { type RecipeVersion } from '$lib/models/recipe_version.model';
import { goto } from '$app/navigation';
import {
sharedKey as sharedKey,
socketAlreadySendHeartbeat,
socketConnectionOfflineCount
} from '../stores/websocketStore';
import { socketAlreadySendHeartbeat, socketConnectionOfflineCount } from '../stores/websocketStore';
import type { RecipePrice } from '$lib/models/price.model';
import { sendCommandRequest, sendMessage } from './ws_messageSender';
import { auth as authStore } from '../stores/auth';
import { v4 as uuidv4 } from 'uuid';
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
import { env } from '$env/dynamic/public';
import { WebCryptoHelper } from '../utils/crypto';
import { GlobalEventBus } from '../utils/eventBus';
import * as semver from 'semver';
export const messages = writable<string[]>([]);
type HandshakeAck = { server_public_key: string; status: string };
type WSMessage = { type: string; payload: any };
// MAXIMUM LIMIT = 1814355
@ -143,7 +131,7 @@ const handlers: Record<string, (payload: any) => void> = {
}
}
},
stream_data_end: async (p) => {
stream_data_end: (p) => {
recipeLoading.set(false);
// build overview for recipe from server
@ -166,7 +154,7 @@ const handlers: Record<string, (payload: any) => void> = {
}
// send next chain message
await sendMessage({
sendMessage({
type: 'price',
payload: {
action: {
@ -295,76 +283,26 @@ const handlers: Record<string, (payload: any) => void> = {
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
const content = p.content ?? p.value ?? p.payload;
const ref = p.ref ?? '';
console.log('[Sheet] Notify content received:', {
msg,
target,
currentUid,
contentKeys: content && typeof content === 'object' ? Object.keys(content) : [],
contentItems: Array.isArray(content) ? content.length : undefined
});
if (!target || (currentUid && target === currentUid)) {
if (!msg && content?.catalogs) {
handleCatalogsResponse(content);
addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`);
return;
}
if (
!msg &&
(content?.priceSlots ||
content?.priceslots ||
content?.price_slots ||
content?.slots ||
content?.param === 'priceslot' ||
content?.option === 'PriceSlot' ||
isPriceSlotsPayload(content))
) {
handlePriceSlotsResponse(content);
addNotification('INFO:Loaded PriceSlot data');
return;
}
if (!msg && ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
addNotification('INFO:Loaded sheet price data');
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 'priceslot':
case 'price_slot':
handlePriceSlotsResponse(content);
addNotification('INFO:Loaded PriceSlot data');
break;
case 'start':
if (ref === 'price') {
addNotification('INFO:Sheet price streaming started');
} else {
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
}
handleSheetStreamStart(p);
addNotification('INFO:Sheet data streaming started');
break;
case 'chunk':
if (isPriceSlotsPayload(content)) {
handlePriceSlotsResponse(content);
} else if (ref === 'price') {
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
} else {
handleSheetStreamChunk(p);
}
handleSheetStreamChunk(p);
break;
case 'end':
if (ref === 'price') {
addNotification('INFO:Sheet price streaming complete');
} else {
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
}
handleSheetStreamEnd(p);
addNotification('INFO:Sheet data streaming complete');
break;
case 'error':
handleSheetStreamError(p);
@ -372,17 +310,8 @@ const handlers: Record<string, (payload: any) => void> = {
break;
default:
// Handle other content notifications from sheet-service
console.log('[Sheet] Received content:', {
contentItems: Array.isArray(content) ? content.length : undefined
});
console.log('[Sheet] Received content:', p.content);
}
} else {
console.warn('[Sheet] Ignored content because target does not match current user:', {
target,
currentUid,
msg,
contentItems: Array.isArray(content) ? content.length : undefined
});
}
return;
}
@ -423,7 +352,7 @@ const handlers: Record<string, (payload: any) => void> = {
currentRecipeVersionsSelector.set(result);
}
},
price: async (p) => {
price: (p) => {
let req_action = p.req_action;
let status = p.status;
let to = p.to;
@ -456,11 +385,10 @@ const handlers: Record<string, (payload: any) => void> = {
current_streaming_instance[request_id] = '';
streamingRawData.set(current_streaming_instance);
await sendCommandRequest('sheet', {
sendCommandRequest('sheet', {
country: current_meta?.country ?? '',
content: saved_product_code_to_get_from_sheet,
param: 'price',
option: 'price',
stream: true,
request_id
});
@ -538,79 +466,32 @@ const handlers: Record<string, (payload: any) => void> = {
// Header for price stream
handleRawStreamHeader('price', p);
},
raw_stream_priceslot: (p) => {
handleRawStreamHeader('priceslot', p);
},
raw_stream_chunk_price: (p) => {
// Chunk for price stream
handleRawStreamChunk('price', p);
},
raw_stream_chunk_priceslot: (p) => {
handleRawStreamChunk('priceslot', p);
},
raw_stream_end_price: (p) => {
// End for price stream
handleRawStreamEnd('price', p);
},
raw_stream_end_priceslot: (p) => {
handleRawStreamEnd('priceslot', p);
},
announce: (p) => {
// Server-pushed announcement (e.g., closing maintenance)
GlobalEventBus.emit('announce', p);
}
};
export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
const APP_VERSION = env.PUBLIC_APP_SEMVER;
const parsedMessage = JSON.parse(raw);
const ack: HandshakeAck = parsedMessage;
if (ack != null && ack.status === 'authenticated') {
// has server response
if (!clientPrivateKey) return;
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
addNotification('INFO:Secured Connection');
export function handleIncomingMessages(raw: string) {
const msg: WSMessage = JSON.parse(raw);
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
if (msg == null) {
// error response
addNotification('ERR:No response from server');
return;
}
if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
// secured message decryption
let sharedKeyStore = get(sharedKey);
if (sharedKeyStore) {
let decrypted_string = await WebCryptoHelper.decryptMessage(
sharedKeyStore,
parsedMessage.ciphertext,
parsedMessage.iv
);
let actual_message: WSMessage = JSON.parse(decrypted_string);
if (actual_message.type !== 'heartbeat') {
// console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
}
handlers[actual_message.type]?.(actual_message.payload);
}
} else {
const msg: WSMessage = parsedMessage;
if (msg.type !== 'heartbeat') {
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
}
if (msg == null) {
// error response
addNotification('ERR:No response from server');
return;
}
// raw streaming type
// if (msg.type.startsWith('raw_stream')) {
// // convert
// let sub_type = msg.type.replace('raw_stream_', '');
// msg.payload.sub_type = sub_type;
// msg.type = 'raw_stream';
// }
// raw streaming type
// if (msg.type.startsWith('raw_stream')) {
// // convert
// let sub_type = msg.type.replace('raw_stream_', '');
// msg.payload.sub_type = sub_type;
// msg.type = 'raw_stream';
// }
handlers[msg.type]?.(msg.payload);
}
handlers[msg.type]?.(msg.payload);
}

View file

@ -1,15 +1,12 @@
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
import { socketStore } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
import { auth } from '../stores/auth';
import { WebCryptoHelper } from '../utils/crypto';
import { env } from '$env/dynamic/public';
import * as semver from 'semver';
export const queue = writable<string[]>([]);
type CommandRequest = 'sheet' | 'command' | 'upload-log';
type CommandRequest = 'sheet' | 'command';
function getServiceName(cmdReq: CommandRequest) {
switch (cmdReq) {
@ -17,45 +14,11 @@ function getServiceName(cmdReq: CommandRequest) {
return 'sheet-service';
case 'command':
return 'command';
case 'upload-log':
return 'upload-log';
}
}
function waitForWsAuthReady(timeoutMs = 10000): Promise<boolean> {
if (get(wsAuthReady)) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(false);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(true);
});
});
}
// Websocket message wrapper for commands like `sheet`, `command`
export async function sendCommandRequest(target: CommandRequest, values: any): Promise<boolean> {
const authReady = await waitForWsAuthReady();
if (!authReady) {
console.warn('[WS Send] Skip command request because websocket auth is not ready', {
target,
param: values?.param
});
return false;
}
export function sendCommandRequest(target: CommandRequest, values: any): boolean {
let srv_name = getServiceName(target);
let curr_user = get(auth);
@ -68,7 +31,7 @@ export async function sendCommandRequest(target: CommandRequest, values: any): P
};
}
return await sendMessage({
return sendMessage({
type: target,
payload: {
user_info: user_info ?? {},
@ -78,13 +41,9 @@ export async function sendCommandRequest(target: CommandRequest, values: any): P
});
}
export async function sendMessage(
msg: OutMessage,
ignore_queue_request: boolean = true
): Promise<boolean> {
const APP_VERSION = env.PUBLIC_APP_SEMVER;
export function sendMessage(msg: OutMessage, ignore_queue_request: boolean = true): boolean {
const socket = get(socketStore);
let data = JSON.stringify(msg);
const data = JSON.stringify(msg);
// console.log('try sending ', data);
@ -105,24 +64,6 @@ export async function sendMessage(
return false;
}
// console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION));
if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
// console.log('sending secured');
let sharedKeyRes = get(sharedKey);
// do encrypt
if (sharedKeyRes != null)
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
}
// console.log('[WS Send]', {
// type: logMessage.type,
// service: logMessage.payload?.srv_name,
// param: logMessage.payload?.values?.param,
// bytes: data.length,
// secured: isSecuredAppVersion(APP_VERSION)
// });
socket.send(data);
return true;
}

View file

@ -7,238 +7,74 @@ import {
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
setPendingProductCodesCountry,
setPendingPriceSlotsCountry,
priceSlotsLoading,
resetPriceSlotsCountry
setPendingProductCodesCountry
} from '../stores/sheetStore';
import type { PriceSlot } from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
type SheetCellUpdate = { value: string; coord: { row: number; col: number } };
type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] };
type SheetRowCreate = { header?: string[]; cells: string[] };
export async function requestCatalogs(country: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function requestCatalogs(country: string): boolean {
return sendCommandRequest('sheet', {
country: country,
param: 'catalogs'
});
}
/**
* Register a newly created catalog as a Grist table so it shows in the overview
* and menus can be added to it. `catalog` is the .skt filename produced by
* /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt").
*/
export async function addCatalog(
country: string,
catalogName: string,
catalog: string
): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function requestPriceSlots(country: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
catalog_name: catalogName,
param: 'add/catalog'
param: 'priceslot'
});
}
export async function requestPriceSlots(country: string): Promise<boolean> {
setPendingPriceSlotsCountry(country);
resetPriceSlotsCountry(country);
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function requestPriceSlot(country: string, slotNumber: number): Promise<boolean> {
setPendingPriceSlotsCountry(country);
return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
}
async function requestPriceSlotOption(country: string, option: string): Promise<boolean> {
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
...data,
priceslot: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
priceSlotsLoading.set(true);
const values = {
country: country,
param: 'price',
option,
stream: true,
request_id
};
console.log('[sheetService] Sending PriceSlot request:', values);
const sent = await sendCommandRequest('sheet', values);
console.log('[sheetService] PriceSlot request sent:', sent);
if (!sent) {
priceSlotsLoading.set(false);
export function updatePriceSlot(
country: string,
content: {
slot: number;
name: string;
description: string;
products: { product_code: string; price: number | null; row_index?: number }[];
}
return sent;
}
export async function refreshPriceSlotList(country: string): Promise<boolean> {
return requestPriceSlotOption(country, 'PriceSlot');
}
export async function updatePriceSlot(
country: string,
slot: PriceSlot,
content: SheetRowUpdate[]
): Promise<boolean> {
// console.log('[sheetService] Sending PriceSlot update:', {
// country,
// slot: slot.slot,
// name: slot.name,
// description: slot.description,
// kind: slot.kind,
// rows: content.length,
// param: 'update/price',
// option: `PriceSlot${slot.slot}`
// });
const sent = await sendCommandRequest('sheet', {
): boolean {
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/price',
option: `PriceSlot${slot.slot}`
param: 'update/priceslot'
});
console.log('[sheetService] PriceSlot update sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlot(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
console.log('[sheetService] Sending PriceSlot create:', {
country,
slot: slot.slot,
name: slot.name,
description: slot.description,
kind: slot.kind,
rows: content.length,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot create sent:', {
country,
slot: slot.slot,
sent
});
return sent;
}
export async function addPriceSlotRows(
country: string,
slot: PriceSlot,
content: SheetRowCreate[]
): Promise<boolean> {
if (!content || content.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows add sent:', {
country,
slot: slot.slot,
rows: content.length,
sent
});
return sent;
}
export async function deletePriceSlotRows(
country: string,
slot: PriceSlot,
rowIds: number[]
): Promise<boolean> {
if (!rowIds || rowIds.length === 0) return true;
const sent = await sendCommandRequest('sheet', {
country: country,
content: rowIds.map((target_id) => ({ target_id })),
param: 'delete/price',
option: `PriceSlot${slot.slot}`
});
console.log('[sheetService] PriceSlot rows delete sent:', {
country,
slot: slot.slot,
rows: rowIds.length,
sent
});
return sent;
}
export async function enterRoom(country: string, catalog: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function enterRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'enter'
});
}
export async function sendHeartbeat(country: string, catalog: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function sendHeartbeat(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'heartbeat'
});
}
export async function exitRoom(country: string, catalog: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function exitRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'exit'
});
}
export async function requestCatalogMenu(country: string, catalog: string): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function requestCatalogMenu(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'catalog/menu'
});
}
export async function updateMenu(
country: string,
catalog: string,
content: any[]
): Promise<boolean> {
return await sendCommandRequest('sheet', {
export function updateMenu(country: string, catalog: string, content: any[]): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@ -246,9 +82,9 @@ export async function updateMenu(
});
}
export async function addMenu(country: string, catalog: string, content: any[]): Promise<boolean> {
export function addMenu(country: string, catalog: string, content: any[]): boolean {
console.log('[sheetService] Adding menu:', { country, catalog, content });
const sent = await sendCommandRequest('sheet', {
const sent = sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@ -258,13 +94,9 @@ export async function addMenu(country: string, catalog: string, content: any[]):
return sent;
}
export async function deleteMenu(
country: string,
catalog: string,
targetIds: number[]
): Promise<boolean> {
export function deleteMenu(country: string, catalog: string, targetIds: number[]): boolean {
const content = targetIds.map((id) => ({ target_id: id }));
return await sendCommandRequest('sheet', {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@ -272,12 +104,12 @@ export async function deleteMenu(
});
}
export async function swapMenu(
export function swapMenu(
country: string,
catalog: string,
swaps: { source_id: number; target_id: number }[]
): Promise<boolean> {
return await sendCommandRequest('sheet', {
): boolean {
return sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: swaps,
@ -285,7 +117,7 @@ export async function swapMenu(
});
}
export async function requestListMenu(country: string, boxid?: string): Promise<boolean> {
export function requestListMenu(country: string, boxid?: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
@ -302,7 +134,7 @@ export async function requestListMenu(country: string, boxid?: string): Promise<
console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
return await sendMessage({
return sendMessage({
type: 'list_menu',
payload: {
user_info,
@ -312,7 +144,7 @@ export async function requestListMenu(country: string, boxid?: string): Promise<
});
}
export async function requestGenLayout(country: string): Promise<boolean> {
export function requestGenLayout(country: string): boolean {
const curr_user = get(auth);
let user_info: any = {};
@ -328,7 +160,7 @@ export async function requestGenLayout(country: string): Promise<boolean> {
console.log('[sheetService] Sending gen-layout request for country:', country);
return await sendMessage({
return sendMessage({
type: 'command',
payload: {
user_info,
@ -347,13 +179,9 @@ export async function requestGenLayout(country: string): Promise<boolean> {
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
export async function requestSheetPrice(
country: string,
productCodes: string[],
force = false
): Promise<boolean> {
export function requestSheetPrice(country: string, productCodes: string[]): boolean {
// Check if already sent
if (!force && hasSheetPriceBeenSent('price')) {
if (hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
@ -382,61 +210,12 @@ export async function requestSheetPrice(
// 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
);
console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id);
const sent = await sendCommandRequest('sheet', {
const sent = sendCommandRequest('sheet', {
country: country,
content: content,
param: 'price',
option: 'price',
stream: true,
request_id
});
console.log('[sheetService] Sheet price request sent:', { country, request_id, sent });
if (sent) {
markSheetPriceAsSent('price');
} else {
sheetPriceLoading.set(false);
}
return sent;
}
export async function requestAllSheetPrice(country: string, force = false): Promise<boolean> {
if (!force && hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
const request_id = crypto.randomUUID();
streamingRawData.update((data) => ({
...data,
price: {
request_id,
country,
chunks: [],
rawParts: []
}
}));
sheetPriceLoading.set(true);
console.log('[sheetService] Sending all sheet price request:', { country, request_id });
const sent = await sendCommandRequest('sheet', {
country,
content: [],
param: 'price',
option: 'price',
stream: true,
request_id
});
@ -454,23 +233,18 @@ export async function requestAllSheetPrice(country: string, force = false): Prom
* Update price data in sheet
* content: [{ row_index: number, cells: [{ value: string, coord: { row: number, col: number } }] }]
*/
export async function updateSheetPrice(
export function updateSheetPrice(
country: string,
content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[]
): Promise<boolean> {
): 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
);
console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length);
return await sendCommandRequest('sheet', {
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/price'
@ -481,24 +255,18 @@ export async function updateSheetPrice(
* 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 async function addSheetPrice(
export function addSheetPrice(
country: string,
content: { cells: string[] }[]
): Promise<boolean> {
): 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
);
console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content);
return await sendCommandRequest('sheet', {
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price'

View file

@ -51,7 +51,6 @@ export const toppingGroupFromServerQuery = writable<any>([]);
export const latestRecipeToppingData = writable<any>([]);
// edit data update
/// NOTE: Will be obsolete in future, and replace with `EventBus` style.
export const recipeDataEvent = writable<{
event_type: string;
payload: any;

View file

@ -24,304 +24,16 @@ export interface PriceSlotProduct {
row_index?: number;
}
export interface PriceSlotServiceRow {
row_index?: number;
cells: Record<string, string>;
}
export interface PriceSlot {
slot: number;
name: string;
description: string;
kind?: 'price' | 'service';
header?: string[];
products: PriceSlotProduct[];
serviceRows?: PriceSlotServiceRow[];
}
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
export const priceSlotNamespaces = writable<Record<string, PriceSlot[]>>({});
export const priceSlotsLoading = writable<boolean>(false);
export const priceSlotsError = writable<string | null>(null);
let pendingPriceSlotsCountry = '';
export function setPendingPriceSlotsCountry(country: string) {
pendingPriceSlotsCountry = country.toLowerCase();
}
export function resetPriceSlotsCountry(country: string) {
const key = country.toLowerCase();
priceSlots.update((data) => ({
...data,
[key]: []
}));
priceSlotNamespaces.update((data) => ({
...data,
[key]: []
}));
priceSlotsError.set(null);
}
function normalizePriceSlotProduct(product: any): PriceSlotProduct | null {
const cells = Array.isArray(product?.cells) ? product.cells : [];
const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value;
const productCode =
product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1);
if (!productCode) return null;
const priceValue =
product?.price ??
product?.Price ??
product?.value ??
product?.cash_price ??
product?.CashPrice ??
cellValue(5);
const price =
priceValue === '' || priceValue === undefined || priceValue === null
? null
: Number(priceValue);
return {
product_code: String(productCode),
name: String(
product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? ''
),
price: Number.isNaN(price) ? null : price,
row_index: product?.row_index ?? product?.row
};
}
function getPriceSlotHeader(slot: any): string[] {
const header = Array.isArray(slot?.header) ? slot.header : [];
return header.map((value: any) => String(value ?? '').trim());
}
function isServicePriceSlotHeader(header: string[]): boolean {
return header.some((value) => value.toLowerCase() === 'servicetype');
}
function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null {
const cells = Array.isArray(row?.cells) ? row.cells : [];
const mappedCells = header.reduce<Record<string, string>>((result, columnName, index) => {
if (!columnName) return result;
const value =
row?.[columnName] ??
row?.[columnName.replace(/\s+/g, '')] ??
cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ??
'';
result[columnName] = String(value ?? '');
return result;
}, {});
if (Object.values(mappedCells).every((value) => value === '')) return null;
return {
row_index: row?.row_index ?? row?.row,
cells: mappedCells
};
}
function normalizePriceSlot(slot: any, index: number): PriceSlot {
const sheetName = slot?.sheet ?? slot?.Sheet;
const displayName = slot?.name ?? slot?.title ?? sheetName;
const slotNumber = Number(
slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1
);
const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? [];
const header = getPriceSlotHeader(slot);
const isServiceSlot = isServicePriceSlotHeader(header);
const headerName = isServiceSlot ? header[12] : header[10];
const headerDescription = isServiceSlot ? header[13] : header[11];
const products = (Array.isArray(productsSource) ? productsSource : [])
.map(normalizePriceSlotProduct)
.filter((product): product is PriceSlotProduct => product !== null);
const serviceRows = isServiceSlot
? (Array.isArray(productsSource) ? productsSource : [])
.map((row) => normalizePriceSlotServiceRow(row, header))
.filter((row): row is PriceSlotServiceRow => row !== null)
: [];
return {
slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber,
name: String(
headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}`
),
description: String(headerDescription ?? ''),
kind: isServiceSlot ? 'service' : 'price',
header,
products: isServiceSlot ? [] : products,
serviceRows
};
}
function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot {
const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1);
const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber;
return {
slot,
name: sheetName || `PriceSlot${slot}`,
description: '',
kind: 'price',
header: [],
products: []
};
}
function getPriceSlotSource(content: any) {
return (
content?.priceSlots ??
content?.priceslots ??
content?.price_slots ??
content?.slots ??
content?.data ??
content?.value ??
content?.content ??
content
);
}
function getPriceSlotItems(content: any): any[] {
const source = getPriceSlotSource(content);
if (Array.isArray(source)) {
return source.flatMap((item) => {
if (Array.isArray(item?.sheet)) {
return item.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
return [item];
});
}
if (Array.isArray(source?.sheet)) {
return source.sheet.map((sheetName: any, index: number) =>
normalizePriceSlotNamespace(String(sheetName ?? ''), index)
);
}
if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source];
if (typeof source === 'object' && source) {
return Object.entries(source).map(([key, value]) => ({
...(typeof value === 'object' && value ? value : {}),
name: (value as any)?.name ?? key
}));
}
return [];
}
export function handlePriceSlotsResponse(content: any) {
console.log('[PriceSlot] Raw backend response:', {
items: Array.isArray(content) ? content.length : undefined,
keys:
content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : []
});
const country = String(
content?.country ?? content?.Country ?? pendingPriceSlotsCountry
).toLowerCase();
const source = getPriceSlotSource(content);
const slotList = getPriceSlotItems(content);
if (!country || slotList.length === 0) {
console.warn('[PriceSlot] No slot list found:', {
country,
sourceItems: Array.isArray(source) ? source.length : undefined
});
priceSlotsError.set('No PriceSlot data found in backend response');
priceSlotsLoading.set(false);
return;
}
const normalizedSlots = slotList.map((slot, index) =>
isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index)
);
if (normalizedSlots.length === 0) {
console.warn('[PriceSlot] Response did not include usable rows:', {
country,
slotListItems: slotList.length
});
return;
}
console.log('[PriceSlot] Normalized slots:', {
country,
slots: normalizedSlots.length,
firstSlot: normalizedSlots[0]
? {
slot: normalizedSlots[0].slot,
name: normalizedSlots[0].name,
kind: normalizedSlots[0].kind,
products: normalizedSlots[0].products.length,
serviceRows: normalizedSlots[0].serviceRows?.length ?? 0
}
: undefined
});
const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any));
if (loadedSlots.length > 0) {
priceSlots.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(slot.slot, slot);
}
for (const slot of loadedSlots) {
merged.set(slot.slot, slot);
}
return {
...data,
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
};
});
}
priceSlotNamespaces.update((data) => {
const merged = new Map<number, PriceSlot>();
for (const slot of data[country] ?? []) {
merged.set(slot.slot, slot);
}
for (const slot of normalizedSlots) {
merged.set(slot.slot, slot);
}
return {
...data,
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
};
});
priceSlotsError.set(null);
priceSlotsLoading.set(false);
}
export function isPriceSlotsPayload(content: any): boolean {
const source = getPriceSlotSource(content);
if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true;
if (Array.isArray(source?.sheet)) {
return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'));
}
if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot');
if (!Array.isArray(source)) return false;
return source.some(
(item) =>
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')))
);
}
function isPriceSlotNamespace(slot: any): slot is PriceSlot {
return (
typeof slot?.slot === 'number' &&
Array.isArray(slot?.products) &&
slot.products.length === 0 &&
Array.isArray(slot?.header) &&
slot.header.length === 0 &&
slot.name?.startsWith?.('PriceSlot')
);
}
export const countryPrimaryLanguageMap: Record<string, string> = {
THAI: 'Thai',
@ -366,19 +78,11 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
// 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,
{
// Column→language map for the new-layout-v2 sheet (menu name/desc rows).
language: Record<string, number>;
// Column→language map for the name-desc-v2 sheet (Translations). Different
// namespace/sheet so the columns can differ from new-layout-v2; falls back
// to `language` when not set (countries where the two are identical).
nameDescLanguage?: Record<string, number>;
productCode: { hot: number; cold: number; blend: number };
primaryLanguage: string;
}
> = {
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 },
@ -386,7 +90,6 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
aus: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3, th: 4, ms: 7 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -397,13 +100,11 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
hkg: {
language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 },
nameDescLanguage: { en: 3, zh_hans: 4, zh_hant: 5 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'zh_hant'
},
ltu: {
language: { en: 3, th: 4, lt: 5, ro: 6 },
nameDescLanguage: { en: 3, lt: 5, ro: 6 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'lt'
},
@ -429,7 +130,6 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
},
sgp: {
language: { en: 3, th: 4 },
nameDescLanguage: { en: 3 },
productCode: { hot: 9, cold: 10, blend: 11 },
primaryLanguage: 'en'
},
@ -451,10 +151,8 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
};
export function getSheetColumnConfig(countryCode: string) {
return (
SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()] ||
SHEET_COLUMN_CONFIG_BY_COUNTRY.default
);
return SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()]
|| SHEET_COLUMN_CONFIG_BY_COUNTRY.default;
}
export function handleCatalogsResponse(content: CatalogsResponse) {
@ -606,13 +304,10 @@ export interface SheetPriceItem {
// 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
}
> = {
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']
@ -671,7 +366,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
// 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());
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;
@ -687,9 +382,7 @@ export const lastRequestSheetPrice = writable<Record<string, Record<string, Gris
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[] }[]>>
>({});
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(
@ -697,43 +390,29 @@ export function getPriceFromCells(
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
const colIdx = getPriceColumnIndex(country, priceType);
if (colIdx < 0) return null;
// Find the cell with matching column index
const priceCell = cells.find((c) => c.coord?.col === colIdx);
return priceCell?.value ?? null;
}
export function getPriceColumnIndex(
country: string,
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): number {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
console.warn(`[getPriceFromCells] No header found for country: ${country}`);
return -1;
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;
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 -1;
console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
return null;
}
return colIdx;
// 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
@ -765,20 +444,15 @@ export const streamingRawData = writable<
// Handler: raw_stream header (e.g., raw_stream_price)
export function handleRawStreamHeader(subtype: string, payload: any) {
let targetSubtype = subtype;
const currentData = get(streamingRawData);
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
console.log(`[RawStream] Header for ${targetSubtype}:`, payload);
console.log(`[RawStream] Header for ${subtype}:`, payload);
// Get existing stream data to preserve country from request
const existingData = currentData[targetSubtype];
const currentData = get(streamingRawData);
const existingData = currentData[subtype];
streamingRawData.update((data) => ({
...data,
[targetSubtype]: {
[subtype]: {
request_id: payload.request_id,
header: payload.header || payload.headers,
country: payload.country || existingData?.country || '',
@ -787,7 +461,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
}
}));
if (targetSubtype === 'price') {
if (subtype === 'price') {
sheetPriceStreamMeta.set({
request_id: payload.request_id,
country: payload.country || existingData?.country || '',
@ -799,21 +473,13 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
// 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);
let targetSubtype = subtype;
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
console.log(
`[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
payload.raw?.length
);
const streamData = currentData[targetSubtype];
const streamData = currentData[subtype];
if (!streamData || streamData.request_id !== payload.request_id) {
console.warn(`[RawStream] Chunk received for unknown stream: ${targetSubtype}`);
console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
return;
}
@ -822,13 +488,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
streamingRawData.update((data) => ({
...data,
[targetSubtype]: {
[subtype]: {
...streamData,
country: payload.country || streamData.country,
rawParts: [...(streamData.rawParts || []), payload.raw]
}
}));
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${targetSubtype}`);
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
return;
}
@ -838,30 +504,25 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
streamingRawData.update((data) => ({
...data,
[targetSubtype]: {
[subtype]: {
...streamData,
country: payload.country || streamData.country,
chunks: [...streamData.chunks, ...contentArray]
}
}));
console.log(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`);
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);
let targetSubtype = subtype;
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
targetSubtype = 'priceslot';
}
console.log(`[RawStream] End payload for ${targetSubtype}:`, payload);
const streamData = currentData[targetSubtype];
const streamData = currentData[subtype];
if (!streamData || streamData.request_id !== payload.request_id) {
console.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
console.warn(`[RawStream] End received for unknown stream: ${subtype}`);
return;
}
@ -893,41 +554,18 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
}
}
console.log(
`[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}`
);
console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
handlePriceSlotsResponse({ country, slots: chunks });
}
if (targetSubtype === 'priceslot') {
priceSlotsLoading.set(false);
}
if (targetSubtype === 'price') {
const looksLikePriceSlot = chunks.some((item) => {
return (
String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
(Array.isArray(item?.sheet) &&
item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) ||
item?.option === 'PriceSlot' ||
item?.param === 'priceslot'
);
});
if (looksLikePriceSlot) {
handlePriceSlotsResponse({ country, slots: chunks });
} else {
processSheetPriceData(country, streamData.header || [], chunks);
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
sheetPriceLoading.set(false);
}
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[targetSubtype];
delete newData[subtype];
return newData;
});
}
@ -962,18 +600,8 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// 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 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)
@ -1074,10 +702,7 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// 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)
);
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];
@ -1085,13 +710,6 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
}
}
export function handleSheetPriceResponse(country: string, content: any) {
const resolvedCountry = country || get(streamingRawData).price?.country || '';
const chunks = Array.isArray(content) ? content : [content];
processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
sheetPriceLoading.set(false);
}
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);
@ -1151,24 +769,14 @@ export function loadProductCodesFromCache(country?: string): boolean {
// 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
);
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'
);
console.log('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown');
return true;
}
}
@ -1190,13 +798,7 @@ export function clearProductCodes() {
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'
);
console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
if (payload && payload.codes) {
existingProductCodes.set(new Set(payload.codes));
@ -1212,12 +814,7 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
timestamp: Date.now()
})
);
console.log(
'[sheetStore] Saved',
payload.codes.length,
'product codes to cache for',
country
);
console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
} catch (e) {
console.warn('[sheetStore] Failed to save to cache:', e);
}

View file

@ -1,28 +0,0 @@
import { writable } from 'svelte/store';
/**
* Store for managing the terminal drawer open/closed state.
* Also holds command history and connection status.
*/
export const terminalDrawerOpen = writable<boolean>(false);
/**
* Toggle the terminal drawer open/closed
*/
export function toggleTerminalDrawer() {
terminalDrawerOpen.update((v) => !v);
}
/**
* Open the terminal drawer
*/
export function openTerminalDrawer() {
terminalDrawerOpen.set(true);
}
/**
* Close the terminal drawer
*/
export function closeTerminalDrawer() {
terminalDrawerOpen.set(false);
}

View file

@ -7,20 +7,15 @@ import { auth } from '../client/firebase';
import { auth as authStore } from '$lib/core/stores/auth';
import { addNotification } from './noti';
import { permission } from './permissions';
import { WebCryptoHelper } from '../utils/crypto';
let socket: WebSocket | null = null;
let reconnectTimeout: any;
let socketCheck: any;
let sendAuthInfoInterval: any;
const ENABLE_WS_DEBUG: boolean = false;
export const socketConnectionOfflineCount = writable<number>(0);
export const socketAlreadySendHeartbeat = writable<number>(0);
export const socketStore = writable<WebSocket | null>(null);
export const wsAuthReady = writable<boolean>(false);
export const sharedKey = writable<CryptoKey | null>(null);
export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
const currentSocket = get(socketStore);
@ -54,32 +49,7 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
});
}
export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise<WebSocket | null> {
const openSocket = await waitForOpenSocket(timeoutMs);
if (!openSocket) return null;
if (get(wsAuthReady)) return openSocket;
return new Promise((resolve) => {
let settled = false;
let unsubscribe = () => {};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
resolve(null);
}, timeoutMs);
unsubscribe = wsAuthReady.subscribe((ready) => {
if (!ready || settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(openSocket);
});
});
}
export async function connectToWebsocket(id_token?: string) {
export function connectToWebsocket(id_token?: string) {
if (browser) {
// console.log('connecting to ', env.PUBLIC_WSS);
try {
@ -87,13 +57,12 @@ export async function connectToWebsocket(id_token?: string) {
return;
}
let ws_url = env.PUBLIC_WSS;
socket = new WebSocket(ws_url);
wsAuthReady.set(false);
sharedKey.set(null);
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
let productionMode = env.PUBLIC_WSS.startsWith('wss');
socket.addEventListener('open', async () => {
let ws_url = productionMode ? `${env.PUBLIC_WSS}?token=${id_token}` : `${env.PUBLIC_WSS}`;
socket = new WebSocket(ws_url);
socket.addEventListener('open', () => {
socketStore.set(socket);
addNotification('INFO:Connected!');
@ -105,45 +74,29 @@ export async function connectToWebsocket(id_token?: string) {
let auth_data = get(authStore);
let perms = get(permission);
socket.send(
JSON.stringify({
token: id_token ?? '',
client_public_key: publicKeyBase64,
client_version: env.PUBLIC_APP_SEMVER
})
);
// 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
});
sendAuthInfoInterval = setInterval(async () => {
if (get(sharedKey)) {
auth_data = get(authStore);
perms = get(permission);
// Debug: check if auth_data has uid
console.log('[WS Auth] Sending auth info with:', {
uid: auth_data?.uid,
name: auth_data?.displayName,
email: auth_data?.email,
date: new Date()
});
const sent = await sendMessage({
type: 'auth',
payload: {
user: {
uid: auth_data?.uid ?? '',
name: auth_data?.displayName ?? '',
email: auth_data?.email ?? '',
permissions: perms.join(',')
}
}
});
wsAuthReady.set(sent);
clearInterval(sendAuthInfoInterval);
sendMessage({
type: 'auth',
payload: {
user: {
uid: auth_data?.uid ?? '',
name: auth_data?.displayName ?? '',
email: auth_data?.email ?? '',
permissions: perms.join(',')
}
}
}, 2000);
});
}
console.log(socket);
// heartbeat 10s
socketCheck = setInterval(async () => {
socketCheck = setInterval(() => {
if (get(socketAlreadySendHeartbeat) > 0) {
let heartbeat_may_offline_count = get(socketConnectionOfflineCount);
@ -155,14 +108,13 @@ export async function connectToWebsocket(id_token?: string) {
socketConnectionOfflineCount.set(0);
socketAlreadySendHeartbeat.set(0);
id_token = await auth.currentUser?.getIdToken(true);
await connectToWebsocket(id_token);
connectToWebsocket(id_token);
return;
}
if (socket != null) {
await sendMessage({
sendMessage({
type: 'heartbeat',
payload: {}
});
@ -178,41 +130,32 @@ export async function connectToWebsocket(id_token?: string) {
if (auth.currentUser && socket == null) {
console.log('try reconnect websocket ...');
// retry again
reconnectTimeout = setTimeout(async () => {
id_token = await auth.currentUser?.getIdToken(true);
await connectToWebsocket(id_token);
reconnectTimeout = setTimeout(() => {
connectToWebsocket(id_token);
}, 5000);
}
});
socket.addEventListener('message', async (event) => {
await handleIncomingMessages(event.data, privateKey);
socket.addEventListener('message', (event) => {
handleIncomingMessages(event.data);
});
socket.addEventListener('close', () => {
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
socket = null;
clearInterval(socketCheck);
clearInterval(sendAuthInfoInterval);
if (auth.currentUser && !socket) {
console.log('try reconnect websocket ...');
// retry again
reconnectTimeout = setTimeout(async () => {
id_token = await auth.currentUser?.getIdToken(true);
await connectToWebsocket(id_token);
}, 5000);
reconnectTimeout = setTimeout(() => connectToWebsocket(id_token), 5000);
}
});
socket.addEventListener('error', (e) => {
// console.log('WebSocket error: ', e);
socketStore.set(null);
wsAuthReady.set(false);
sharedKey.set(null);
});
} catch (socket_error: any) {
if (ENABLE_WS_DEBUG) {

View file

@ -1,5 +1,4 @@
export type OutMessage =
| { token: any; client_public_key: any }
| { type: 'chat'; payload: string }
| { type: 'ping' }
| { type: 'lock'; payload: { field: string } }
@ -48,14 +47,14 @@ export type OutMessage =
payload: {};
}
| {
type: 'sheet' | 'command' | 'upload-log';
type: 'sheet' | 'command';
payload: {
user_info: any;
srv_name: string;
values: any;
};
}
| {
| {
type: 'list_menu';
payload: {
user_info: any;
@ -63,6 +62,7 @@ export type OutMessage =
boxid?: string;
};
}
| {
type: 'price';
payload: {

View file

@ -1,78 +0,0 @@
export class WebCryptoHelper {
private static bytesToBase64(bytes: Uint8Array) {
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
static async generateKeyPair() {
const keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveKey', 'deriveBits']
);
const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
return { privateKey: keyPair.privateKey, publicKeyBase64 };
}
static async deriveSharedKey(clientPrivateKey: any, serverPublicKeyBase64: any) {
const binarySign = atob(serverPublicKeyBase64);
const bytes = new Uint8Array(binarySign.length);
for (let i = 0; i < binarySign.length; i++) {
bytes[i] = binarySign.charCodeAt(i);
}
const importedServerPublic = await window.crypto.subtle.importKey(
'raw',
bytes,
{ name: 'ECDH', namedCurve: 'P-256' },
true,
[]
);
return await window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: importedServerPublic },
clientPrivateKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
static async decryptMessage(aesKey: any, ciphertextBase64: any, ivBase64: any) {
const rawCipher = Uint8Array.from(atob(ciphertextBase64), (c) => c.charCodeAt(0));
const rawIv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0));
const decryptedBuffer = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: rawIv },
aesKey,
rawCipher
);
return new TextDecoder().decode(decryptedBuffer);
}
// Encrypt outgoing messages before sending them to your Axum backend
static async encryptMessage(aesKey: any, plainText: any) {
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12-byte nonce
const encodedText = new TextEncoder().encode(plainText);
const ciphertextBuffer = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
aesKey,
encodedText
);
const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
return { ciphertext: ciphertextBase64, iv: ivBase64 };
}
}

View file

@ -1,117 +0,0 @@
import { getContext, setContext } from 'svelte';
const COMMON_BUS = Symbol('g-event');
class EventBus {
#listeners = new Map();
/**
* Register event with callback on this channel
* @param event
* @param callback
* @returns unsubscribe function of this event, remove callback out of this event
*/
on(event: string, callback: any) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(callback);
// return unsubscribe
return () => this.#listeners.get(event).delete(callback);
}
/**
* Emit data to this event, call every registered callbacks
* @param event
* @param data
*/
emit(event: string, data: any) {
if (this.#listeners.has(event)) {
this.#listeners.get(event).forEach((cb: any) => cb(data));
}
}
emitUntilConsumed(event: string, data: any, timeout?: number) {
if (this.#listeners.has(event)) {
let listener_count = this.#listeners.get(event).length;
if (listener_count == 0) {
setTimeout(
() => {
this.emitUntilConsumed(event, data);
},
(timeout ?? 1) * 1000
);
} else {
this.emit(event, data);
}
}
}
/**
* Clear all listeners
*/
clear() {
this.#listeners.clear();
}
/**
* Clear all callbacks on this event
* @param event
*/
resetCallbackOnEvent(event: string) {
if (this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
}
}
/**
* Initialize the common channel event bus
* @returns EventBus | undefined
*/
function setEventBus(): EventBus | undefined {
return setContext(COMMON_BUS, new EventBus());
}
/**
* Get common channel event bus, cannot be used in non-component
* @returns EventBus | undefined
*/
function useEventBus(): EventBus | undefined {
return getContext(COMMON_BUS);
}
/**
* Initialize the channel with name event bus
* @param name channel name
* @returns EventBus | undefined
*/
function setEventBusWithName(name: string): EventBus | undefined {
return setContext(name, new EventBus());
}
/**
* Get a specific channel event bus, cannot be used in non-component
* @param name channel name
* @returns EventBus | undefined
*/
function useEventBusWithName(name: string): EventBus | undefined {
return getContext(name);
}
/**
* Global type event bus, allow use without Svelte context
*/
const GlobalEventBus = new EventBus();
export {
setEventBus,
useEventBus,
setEventBusWithName,
useEventBusWithName,
COMMON_BUS,
GlobalEventBus
};

View file

@ -272,7 +272,5 @@ export {
getMaterialType,
getCategories,
isNonMaterial,
extractMaterialIdFromDisplay,
getMenuStatus,
buildTags
extractMaterialIdFromDisplay
};

View file

@ -18,22 +18,10 @@
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let { children } = $props();
let websocketConnectedForUid = $state('');
let adbReconnectTriedForUid = $state('');
let TerminalDrawerComponent: any = $state(null);
// Dynamic import: TerminalDrawer depends on @xterm/xterm which references
// browser globals (self, window). Static import would crash SSR evaluation.
onMount(async () => {
if (browser) {
const mod = await import('$lib/components/terminal-drawer.svelte');
TerminalDrawerComponent = mod.default;
}
});
function getAutoConnectChannel(pathname: string) {
if (pathname.startsWith('/tools/create-menu')) {
@ -109,8 +97,8 @@
websocketConnectedForUid = currentUser.uid;
console.log('connect ws after auth ready');
void currentUser.getIdToken(true).then(async (idToken) => {
await connectToWebsocket(idToken);
void currentUser.getIdToken().then((idToken) => {
connectToWebsocket(idToken);
});
}
@ -137,8 +125,4 @@
<Sidebar.Trigger />
{@render children()}
</main>
{#if TerminalDrawerComponent}
<svelte:component this={TerminalDrawerComponent} />
{/if}
</Sidebar.Provider>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -58,9 +58,9 @@
}
});
async function sendGetRecipeVersions(country: string) {
function sendGetRecipeVersions(country: string) {
version_list = [];
await sendMessage({
sendMessage({
type: 'recipe_versions',
payload: {
auth: '',

View file

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

View file

@ -459,13 +459,13 @@
console.warn('Failed to get boxid from machine:', e);
}
}
await requestListMenu(country, boxid);
requestListMenu(country, boxid);
// Load available product codes from all sources
await loadAvailableProductCodes();
// Enter room to get lock
const entered = await enterRoom(country, catalog);
const entered = enterRoom(country, catalog);
if (entered) {
addNotification(`INFO:Entered ${getCatalogDisplayName(catalog)} for adding menu`);
@ -815,15 +815,13 @@
<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'
: ''}"
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 px-1.5 py-0 text-[10px] hover:bg-green-600">NEW</Badge>
<Badge class="bg-green-600 hover:bg-green-600 text-[10px] px-1.5 py-0">NEW</Badge>
{/if}
</div>
{#if item.name}

View file

@ -48,9 +48,7 @@
Eye,
Cog,
Upload,
RotateCcw,
Usb,
MonitorSmartphone
RotateCcw
} from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
@ -160,8 +158,7 @@
const primaryLanguageKey = columnConfig.primaryLanguage;
const primaryLanguageColumn = languageColumnMap[primaryLanguageKey] ?? languageColumnMap.en ?? 3;
const secondaryLanguageKey = primaryLanguageKey === 'en' ? 'th' : 'en';
const secondaryLanguageColumn =
languageColumnMap[secondaryLanguageKey] ?? languageColumnMap.en ?? 3;
const secondaryLanguageColumn = languageColumnMap[secondaryLanguageKey] ?? languageColumnMap.en ?? 3;
function getCatalogDisplayName(catalogName: string): string {
const match = catalogName.match(/page_catalog_group_(\w+)\.skt/);
@ -346,84 +343,9 @@
}
editingItem = JSON.parse(JSON.stringify(item)); // Deep copy
// Translations (name-desc-v2) must show EVERY language configured for the
// country, even ones with no value yet (e.g. tha = English/Thai/China/Myanmar)
// — otherwise empty languages would be filtered out and disappear.
ensureNameDescLanguageCells(editingItem);
// Snapshot which fields are shown at edit-start so they stay editable even
// after the user clears them (otherwise live-filtering by isMeaningfulValue
// would drop a field the moment its text is emptied).
captureEditFieldKeys(editingItem);
isEditMode = true;
}
// name-desc-v2 language columns (sorted), for the Translations card.
function nameDescColumns(): number[] {
return Object.values(nameDescLanguageMap).sort((a, b) => a - b);
}
// Make sure every name/desc section has a cell for each configured language
// column, creating empty ones where missing so all languages are editable.
function ensureNameDescLanguageCells(item: any) {
const cols = nameDescColumns();
for (const section of item?.name_desc_v2 ?? []) {
if (!section?.key?.endsWith('.name') && !section?.key?.endsWith('.desc')) continue;
if (!Array.isArray(section.cells)) section.cells = [];
const row = section.cells[0]?.coord?.row ?? section.row_index;
for (const col of cols) {
if (!section.cells.some((c: SheetCell) => c.coord.col === col)) {
section.cells.push({ value: '', coord: { row, col } });
}
}
}
}
// Translations card: show every language column (existing or just-created).
function getNameDescEditSections(sections: SheetSection[] | undefined): SheetSection[] {
return (
sections?.filter(
(s) => s.key?.endsWith('.name') || s.key?.endsWith('.desc')
) ?? []
);
}
function getNameDescEditCells(section: SheetSection): SheetCell[] {
const cols = nameDescColumns();
return (
section.cells
?.filter((cell) => cols.includes(cell.coord.col))
.sort((a, b) => a.coord.col - b.coord.col) ?? []
);
}
let editFieldKeys = $state<Set<string>>(new Set());
function editFieldKey(section: SheetSection, cell: SheetCell): string {
return `${section.row_index}|${section.key ?? ''}|${cell.coord.col}`;
}
function captureEditFieldKeys(item: any) {
const keys = new Set<string>();
for (const list of [item?.new_layout_v2, item?.name_desc_v2]) {
for (const section of getVisibleSections(list)) {
for (const cell of getVisibleCells(section)) {
keys.add(editFieldKey(section, cell));
}
}
}
editFieldKeys = keys;
}
// Edit-mode variants: show the fields captured at edit-start (not live-filtered),
// so emptying a field doesn't make its input disappear.
function getEditCells(section: SheetSection): SheetCell[] {
return section.cells?.filter((cell) => editFieldKeys.has(editFieldKey(section, cell))) ?? [];
}
function getEditSections(sections: SheetSection[] | undefined): SheetSection[] {
return sections?.filter((section) => getEditCells(section).length > 0) ?? [];
}
function cancelEdit() {
editingItem = null;
isEditMode = false;
@ -602,14 +524,11 @@
en: 'English',
th: 'Thai',
zh: 'Chinese',
zh_hans: 'Mandarin (Simplified)',
zh_hant: 'Mandarin (Traditional)',
ja: 'Japanese',
ms: 'Malay',
my: 'Myanmar',
lt: 'Lithuanian',
ro: 'Romanian',
ar: 'Arabic'
ro: 'Romanian'
};
const languageLabelsByColumn = Object.fromEntries(
Object.entries(languageColumnMap).map(([key, column]) => [
@ -618,16 +537,6 @@
])
) as Record<number, string>;
// name-desc-v2 is a different sheet namespace whose column→language mapping can
// differ from new-layout-v2 → use a separate label map for the Translations card.
const nameDescLanguageMap = columnConfig.nameDescLanguage ?? languageColumnMap;
const nameDescLabelsByColumn = Object.fromEntries(
Object.entries(nameDescLanguageMap).map(([key, column]) => [
column,
languageLabelsByKey[key] ?? key.toUpperCase()
])
) as Record<number, string>;
const nameColumnLabels: Record<number, string> = {
9: 'Hot product codes',
10: 'Cold product codes',
@ -686,13 +595,13 @@
}
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
if (section.key?.endsWith('.name')) {
return nameDescLabelsByColumn[cell.coord.col]
? `${nameDescLabelsByColumn[cell.coord.col]} name`
return languageLabelsByColumn[cell.coord.col]
? `${languageLabelsByColumn[cell.coord.col]} name`
: `Column ${cell.coord.col}`;
}
if (section.key?.endsWith('.desc')) {
return nameDescLabelsByColumn[cell.coord.col]
? `${nameDescLabelsByColumn[cell.coord.col]} description`
return languageLabelsByColumn[cell.coord.col]
? `${languageLabelsByColumn[cell.coord.col]} description`
: `Column ${cell.coord.col}`;
}
return `Column ${cell.coord.col}`;
@ -717,33 +626,10 @@
: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3';
}
// ── Machine (ADB) connect for Preview ────────────────────────────────────
let connectDialogOpen = $state(false);
let adbConnecting = $state(false);
async function connectMachineForPreview() {
adbConnecting = true;
try {
// no Android server socket needed for preview — only file reads
await adb.connnectViaWebUSB(false);
if (AdbInstance.instance) {
addNotification('Machine connected');
connectDialogOpen = false;
// Continue straight into the preview the user originally asked for.
await loadPreviewMenus();
}
} catch (e) {
addNotification('ERR:' + (e instanceof Error ? e.message : 'Connect failed'));
} finally {
adbConnecting = false;
}
}
// Preview functions
async function loadPreviewMenus() {
if (!AdbInstance.instance) {
// Prompt the user to connect first (same UX as other pages).
connectDialogOpen = true;
addNotification('ERR:Machine not connected');
return;
}
@ -911,11 +797,7 @@
}
// Get all prices for an item (hot, cold, blend)
function getItemPrices(item: any): {
hot: string | null;
cold: string | null;
blend: string | null;
} {
function getItemPrices(item: any): { hot: string | null; cold: string | null; blend: string | null } {
return {
hot: getItemPrice(item, 'hot'),
cold: getItemPrice(item, 'cold'),
@ -925,23 +807,18 @@
// Price edit dialog state
let priceEditDialogOpen = $state(false);
let priceEditData = $state<
{ code: string; type: string; price: string; row: number; col: number; isNew: boolean }[]
>([]);
let priceEditData = $state<{ code: string; type: string; price: string; row: number; col: number; isNew: boolean }[]>([]);
let savingPrice = $state(false);
// Get price info for a single product code
function getPriceInfoForCode(
productCode: string
): { price: string; row: number; col: number } | null {
function getPriceInfoForCode(productCode: string): { price: string; row: number; col: number } | null {
const priceCells = sheetPrices[productCode];
if (!priceCells || priceCells.length === 0) return null;
const headers = get(sheetPriceHeader)[countryCode];
if (!headers || headers.length === 0) return null;
const headerNames =
PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const colIdx = findHeaderIndex(headers, headerNames.cash_price);
if (colIdx < 0) return null;
@ -971,14 +848,7 @@
function openPriceEditDialog() {
if (!editingItem) return;
const data: {
code: string;
type: string;
price: string;
row: number;
col: number;
isNew: boolean;
}[] = [];
const data: { code: string; type: string; price: string; row: number; col: number; isNew: boolean }[] = [];
for (const code of editingItem.product_codes || []) {
const info = getPriceInfoForCode(code);
@ -999,10 +869,7 @@
// Save price changes from dialog
async function handleSavePricesFromDialog() {
const updates: {
row_index: number;
cells: { value: string; coord: { row: number; col: number } }[];
}[] = [];
const updates: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[] = [];
const newPrices: { cells: string[] }[] = [];
// Get all rows data for duplicate handling
@ -1010,8 +877,7 @@
// Get header for this country to build cells array correctly
const priceHeaders = get(sheetPriceHeader)[countryCode] || [];
const headerNames =
PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
const priceColIdx = findHeaderIndex(priceHeaders, headerNames.cash_price);
for (const item of priceEditData) {
@ -1021,7 +887,7 @@
if (item.price && item.price.trim()) {
// Build cells array based on header structure
// Find column indices from header
const nameColIdx = priceHeaders.findIndex((h) => h.toLowerCase() === 'name') + 1;
const nameColIdx = priceHeaders.findIndex(h => h.toLowerCase() === 'name') + 1;
// Create cells array with correct length
const cells: string[] = new Array(priceHeaders.length).fill('');
@ -1070,24 +936,20 @@
for (const rowEntry of rowsForCode) {
updates.push({
row_index: rowEntry.row,
cells: [
{
value: item.price,
coord: { row: rowEntry.row, col: item.col }
}
]
cells: [{
value: item.price,
coord: { row: rowEntry.row, col: item.col }
}]
});
}
} else if (item.row) {
// Single row - use the original logic
updates.push({
row_index: item.row,
cells: [
{
value: item.price,
coord: { row: item.row, col: item.col }
}
]
cells: [{
value: item.price,
coord: { row: item.row, col: item.col }
}]
});
}
}
@ -1104,12 +966,12 @@
// Send updates for existing prices
if (updates.length > 0) {
updateSent = await updateSheetPrice(country, updates);
updateSent = updateSheetPrice(country, updates);
}
// Send adds for new prices
if (newPrices.length > 0) {
addSent = await addSheetPrice(country, newPrices);
addSent = addSheetPrice(country, newPrices);
}
if (updateSent && addSent) {
@ -1282,20 +1144,6 @@
}
}
// Map a gen-service file path to the machine path, PRESERVING the structure
// under taobin_project. The old code flattened to `${v3}/${basename}`, which sent
// nested files (banners in event_v3/<slug>/, generated .ev in event/, even
// active_promotions.lxml) to the wrong place on the machine.
function genFileToMachinePath(genFilePath: string, countryCode: string): string {
const marker = '/taobin_project/';
const idx = genFilePath.indexOf(marker);
if (idx >= 0) {
return `${sourceDir}/taobin_project/${genFilePath.slice(idx + marker.length)}`;
}
const filename = genFilePath.split('/').pop() || '';
return `${sourceDir}/taobin_project/inter/${countryCode}/xml/multi/v3/${filename}`;
}
async function pushLayoutFilesToAndroid() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected');
@ -1327,9 +1175,9 @@
try {
for (let i = 0; i < filesToPush.length; i++) {
const file = filesToPush[i];
const androidPath = genFileToMachinePath(file.file, country);
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
await adb.executeCmd(`mkdir -p "${parentDir}"`);
const filename = file.file.split('/').pop() || '';
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: filesToPush.length };
@ -1363,9 +1211,9 @@
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const androidPath = genFileToMachinePath(file.file, country);
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
await adb.executeCmd(`mkdir -p "${parentDir}"`);
const filename = file.file.split('/').pop() || '';
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: files.length };
@ -1597,7 +1445,7 @@
await new Promise((resolve) => setTimeout(resolve, 300));
// Step 1: Enter room via WebSocket (acquire lock)
const entered = await enterRoom(country, catalog);
const entered = enterRoom(country, catalog);
if (!entered) {
addNotification('ERR:WebSocket not connected');
sheetLoading.set(false);
@ -1609,8 +1457,8 @@
// Step 2: Request menu data (this triggers streaming)
// Small delay to ensure enter request is processed first
setTimeout(async () => {
const requested = await requestCatalogMenu(country, catalog);
setTimeout(() => {
const requested = requestCatalogMenu(country, catalog);
if (requested) {
console.log('[Edit] Requested menu data via WebSocket');
} else {
@ -1805,11 +1653,7 @@
recipe: recipe01_query
}));
console.log(
'[Edit] Loaded',
Object.keys(recipe01_query).length,
'recipes from machine'
);
console.log('[Edit] Loaded', Object.keys(recipe01_query).length, 'recipes from machine');
break;
}
} catch (parseError) {
@ -2121,21 +1965,13 @@
<div class="flex items-center gap-4">
<div class="flex flex-wrap gap-4 text-sm">
{#if displayPrices.hot}
<span
><span class="text-muted-foreground">Hot</span> {displayPrices.hot}</span
>
<span><span class="text-muted-foreground">Hot</span> {displayPrices.hot}</span>
{/if}
{#if displayPrices.cold}
<span
><span class="text-muted-foreground">Cold</span>
{displayPrices.cold}</span
>
<span><span class="text-muted-foreground">Cold</span> {displayPrices.cold}</span>
{/if}
{#if displayPrices.blend}
<span
><span class="text-muted-foreground">Blend</span>
{displayPrices.blend}</span
>
<span><span class="text-muted-foreground">Blend</span> {displayPrices.blend}</span>
{/if}
</div>
<Button
@ -2204,13 +2040,13 @@
</Card.Content>
</Card.Root>
{#if getEditSections(editingItem.new_layout_v2).length > 0}
{#if getVisibleSections(editingItem.new_layout_v2).length > 0}
<Card.Root>
<Card.Header>
<Card.Title>Menu Data</Card.Title>
</Card.Header>
<Card.Content class="space-y-5">
{#each getEditSections(editingItem.new_layout_v2) as section}
{#each getVisibleSections(editingItem.new_layout_v2) as section}
<section class="rounded-md border bg-muted/25 p-4">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">{getSectionTitle(section)}</h3>
@ -2219,7 +2055,7 @@
>
</div>
<div class={getSectionGridClass(section)}>
{#each getEditCells(section) as cell}
{#each getVisibleCells(section) as cell}
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground uppercase">
{getCellLabel(section, cell)}
@ -2238,13 +2074,13 @@
</Card.Root>
{/if}
{#if getNameDescEditSections(editingItem.name_desc_v2).length > 0}
{#if getVisibleSections(editingItem.name_desc_v2).length > 0}
<Card.Root>
<Card.Header>
<Card.Title>Names & Descriptions Topping Page</Card.Title>
<Card.Title>Translations</Card.Title>
</Card.Header>
<Card.Content class="space-y-5">
{#each getNameDescEditSections(editingItem.name_desc_v2) as section}
{#each getVisibleSections(editingItem.name_desc_v2) as section}
<section class="rounded-md border bg-muted/25 p-4">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="truncate font-mono text-sm font-semibold">{section.key}</h3>
@ -2253,7 +2089,7 @@
>
</div>
<div class="grid gap-4 md:grid-cols-2">
{#each getNameDescEditCells(section) as cell}
{#each getVisibleCells(section) as cell}
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground uppercase">
{getCellLabel(section, cell)}
@ -2277,13 +2113,7 @@
<!-- View Mode -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
{#each visibleMenuItems as item, index (item.row_index)}
<Card.Root
class="group relative transition-shadow hover:shadow-md {newlyAddedRowIndices.has(
item.row_index
)
? 'ring-2 ring-green-500'
: ''}"
>
<Card.Root class="group relative transition-shadow hover:shadow-md {newlyAddedRowIndices.has(item.row_index) ? 'ring-2 ring-green-500' : ''}">
<Card.Content class="flex min-h-[340px] flex-col items-center p-5 text-center">
<!-- NEW badge for newly added items -->
{#if newlyAddedRowIndices.has(item.row_index)}
@ -2364,19 +2194,13 @@
{#if prices.hot || prices.cold || prices.blend}
<div class="mt-3 flex w-full justify-center gap-3 text-sm">
{#if prices.hot}
<span title="Hot"
><span class="text-muted-foreground">Hot</span> {prices.hot}</span
>
<span title="Hot"><span class="text-muted-foreground">Hot</span> {prices.hot}</span>
{/if}
{#if prices.cold}
<span title="Cold"
><span class="text-muted-foreground">Cold</span> {prices.cold}</span
>
<span title="Cold"><span class="text-muted-foreground">Cold</span> {prices.cold}</span>
{/if}
{#if prices.blend}
<span title="Blend"
><span class="text-muted-foreground">Blend</span> {prices.blend}</span
>
<span title="Blend"><span class="text-muted-foreground">Blend</span> {prices.blend}</span>
{/if}
</div>
{/if}
@ -2449,41 +2273,6 @@
</Dialog.Content>
</Dialog.Root>
<!-- Connect machine (ADB) prompt — shown when Preview is clicked while disconnected -->
<Dialog.Root bind:open={connectDialogOpen}>
<Dialog.Content class="sm:max-w-[420px]">
<Dialog.Header>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<MonitorSmartphone class="h-5 w-5" />
</div>
<div>
<Dialog.Title>Connect machine</Dialog.Title>
<Dialog.Description>
Preview reads recipes from the machine. Connect over USB to continue.
</Dialog.Description>
</div>
</div>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (connectDialogOpen = false)} disabled={adbConnecting}>
Cancel
</Button>
<Button onclick={connectMachineForPreview} disabled={adbConnecting}>
{#if adbConnecting}
<Spinner class="mr-2 h-4 w-4" />
Connecting…
{:else}
<Usb class="mr-2 h-4 w-4" />
Connect
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Preview Online Menus Dialog -->
<Dialog.Root bind:open={previewDialogOpen}>
<Dialog.Content class="w-[98vw] sm:max-w-[1600px]">
@ -2821,21 +2610,15 @@
<div class="max-h-[400px] space-y-3 overflow-y-auto py-4">
{#each priceEditData as item, index}
<div
class="flex items-center gap-3 rounded-lg border p-3 {item.isNew
? 'border-amber-500/50 bg-amber-500/5'
: 'bg-muted/30'}"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 rounded-lg border p-3 {item.isNew ? 'border-amber-500/50 bg-amber-500/5' : 'bg-muted/30'}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<Badge variant="outline" class="text-xs">
{item.type}
</Badge>
<span class="truncate font-mono text-sm">{item.code}</span>
<span class="font-mono text-sm truncate">{item.code}</span>
{#if item.isNew}
<Badge class="bg-amber-600 px-1.5 py-0 text-[10px] hover:bg-amber-600"
>NO PRICE</Badge
>
<Badge class="bg-amber-600 hover:bg-amber-600 text-[10px] px-1.5 py-0">NO PRICE</Badge>
{/if}
</div>
</div>
@ -2852,12 +2635,16 @@
{/each}
{#if priceEditData.length === 0}
<div class="py-8 text-center text-muted-foreground">No product codes found</div>
<div class="py-8 text-center text-muted-foreground">
No product codes found
</div>
{/if}
</div>
<div class="flex justify-end gap-3">
<Button variant="outline" onclick={() => (priceEditDialogOpen = false)}>Cancel</Button>
<Button variant="outline" onclick={() => (priceEditDialogOpen = false)}>
Cancel
</Button>
<Button onclick={handleSavePricesFromDialog} disabled={savingPrice}>
{#if savingPrice}
<Spinner class="mr-2 h-4 w-4" />

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -37,16 +37,11 @@
clearMenuSaveState
} from '$lib/core/stores/menuSaveStore';
import * as semver from 'semver';
import { GlobalEventBus } from '$lib/core/utils/eventBus';
const sourceDir = '/sdcard/coffeevending';
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
const APP_VERSION = env.PUBLIC_APP_SEMVER;
// fetched recipe
let devRecipe: any | undefined = $state();
@ -68,32 +63,6 @@
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
let isRecipeLoaded = $derived(Boolean(devRecipe));
// clear out event
GlobalEventBus.on('recipe-event', (d: any) => {
console.log('[recipe-ev] get event: ', d);
if (d?.type == 'load-recipe' && d?.status == 'end') {
addNotification('INFO:Get data, waiting for reloading ...');
// load finish
//\
let recipeRaw = get(recipeFromMachine);
if (recipeRaw) {
devRecipe = recipeRaw;
// update material & topping
console.log('check dev recipe', devRecipe);
}
// data.recipes = r01Q.recipe;
buildOverviewForBrewing();
console.log('refresh by m2 mem recipe data done');
recipeLoading = false;
addNotification('INFO:Load recipe from memories success!');
}
});
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
for (let attempt = 1; attempt <= attempts; attempt++) {
const content = await adb.pull(path, timeoutMs);
@ -113,51 +82,35 @@
console.log('check instance', instance);
if (instance) {
recipeLoading = true;
try {
console.log('instance passed!');
const recipePaths = [
`${sourceDir}/cfg/recipe_branch_dev.json`,
`${sourceDir}/coffeethai02.json`
];
if (semver.satisfies(APP_VERSION, '^0.0.3')) {
try {
addNotification('WARN:Load recipe from app memories ...');
sendToAndroid({
type: 'get_recipe',
payload: {}
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;
// GlobalEventBus.emit('recipe-event', 'wait-finish');
} finally {
recipeLoading = false;
}
} else {
try {
console.log('instance passed!');
const recipePaths = [
`${sourceDir}/cfg/recipe_branch_dev.json`,
`${sourceDir}/coffeethai02.json`
];
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);
buildOverviewForBrewing();
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
}
try {
devRecipe = JSON.parse(dev_recipe);
buildOverviewForBrewing();
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
}
addNotification('ERROR:Cannot fetch recipe from machine');
} finally {
recipeLoading = false;
}
addNotification('ERROR:Cannot fetch recipe from machine');
} finally {
recipeLoading = false;
}
} else {
addNotification('ERROR:Cannot connect to machine');
@ -190,12 +143,12 @@
errors: []
});
// handleIncomingMessages(
// JSON.stringify({
// type: 'chat',
// payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
// })
// );
handleIncomingMessages(
JSON.stringify({
type: 'chat',
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
})
);
} else {
addNotification('ERROR:Failed to get machine info');
}
@ -817,7 +770,6 @@
onDestroy(() => {
clearOnMenuSavedCallback();
void adb.goToMachineHome();
});
$effect(() => {

View file

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

View file

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

View file

@ -7,7 +7,7 @@
import { AdbInstance } from './state.svelte';
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
import { onMount, setContext } from 'svelte';
import { onMount } from 'svelte';
import { onAuthStateChanged } from 'firebase/auth';
import { auth as authStore, authInitialized } from '$lib/core/stores/auth';
import { auth } from '$lib/core/client/firebase';
@ -26,20 +26,9 @@
setCookieOnNonBrowser
} from '$lib/helpers/cookie';
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
import { GlobalEventBus } from '$lib/core/utils/eventBus';
import AnnouncementDialog from '$lib/components/AnnouncementDialog.svelte';
import * as semver from 'semver';
import { env } from '$env/dynamic/public';
let { children } = $props();
const APP_VERSION = env.PUBLIC_APP_SEMVER;
if (semver.satisfies(APP_VERSION, '^0.0.3')) {
// clean event bus
GlobalEventBus.clear();
}
onMount(() => {
console.log('base url', window.location.origin, document.cookie);
@ -92,5 +81,4 @@
<ModeWatcher />
<Toaster />
<AnnouncementDialog />
{@render children()}

View file

@ -1,24 +0,0 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const GET_IMAGE = env.PUBLIC_GET_IMAGE;
// Server-side fetch of a banner image so the browser can read its bytes
// (to push to the machine via ADB) without hitting CORS on the image server.
export const GET: RequestHandler = async ({ url }) => {
const path = url.searchParams.get('path');
if (!path) throw error(400, 'Missing path');
const target = `${GET_IMAGE}/${path}`;
const res = await fetch(target);
if (!res.ok) throw error(res.status, 'Banner fetch failed');
const buf = await res.arrayBuffer();
return new Response(buf, {
headers: {
'content-type': res.headers.get('content-type') || 'application/octet-stream',
'cache-control': 'no-store'
}
});
};

View file

@ -1,40 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_POST_IMAGE;
// Replace an existing promo's banner image.
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const country = formData.get('country') as string;
const slug = formData.get('slug') as string;
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const banner = formData.get('banner') as File;
if (!country || !slug || !uid || !displayName || !email || !banner) {
throw error(400, 'Missing required fields');
}
const endpoint =
`${API_BASE}/catalog/banner/${encodeURIComponent(country)}/${encodeURIComponent(slug)}` +
`/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
const upstream = new FormData();
upstream.append('banner', banner);
const response = await fetch(endpoint, { method: 'POST', body: upstream });
if (!response.ok) {
const data = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, data.detail || 'Replace banner failed');
}
return json(await response.json());
} catch (err) {
if (err && typeof err === 'object' && 'status' in err) throw err;
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};

View file

@ -1,62 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
// New promo catalogs are created by the same taobin_image service as menu images.
const 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 slug = formData.get('slug') as string;
const name = formData.get('name') as string;
const start = formData.get('start') as string;
// 'NONE' means open-ended (no end date).
const end = (formData.get('end') as string) || 'NONE';
const bannerIndex = (formData.get('banner_index') as string) ?? '1';
const banner = formData.get('banner') as File;
if (!country || !uid || !displayName || !email || !slug || !name || !start || !banner) {
throw error(400, 'Missing required fields');
}
const endpoint =
`${API_BASE}/catalog/create/${encodeURIComponent(country)}/${encodeURIComponent(uid)}` +
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
console.log('[Catalog Create Proxy] Endpoint:', endpoint, 'slug:', slug);
const upstream = new FormData();
upstream.append('slug', slug);
upstream.append('name', name);
upstream.append('start', start);
upstream.append('end', end);
upstream.append('banner_index', bannerIndex);
upstream.append('banner', banner);
const response = await fetch(endpoint, {
method: 'POST',
body: upstream
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Create catalog failed');
}
return json(await response.json());
} catch (err) {
console.error('[Catalog Create 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

@ -1,26 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_POST_IMAGE;
// List web-created promos (slug, banner path, schedule) for a country.
export const GET: RequestHandler = async ({ url }) => {
try {
const country = url.searchParams.get('country');
if (!country) throw error(400, 'Missing country');
const endpoint = `${API_BASE}/catalog/list/${encodeURIComponent(country)}`;
// POST: the Kong route fronting taobin-image only allows POST.
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
const data = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, data.detail || 'List catalogs failed');
}
return json(await response.json());
} catch (err) {
if (err && typeof err === 'object' && 'status' in err) throw err;
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};

View file

@ -1,80 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
// Main-page advertisement videos are wired into video/script1.ev by the same
// taobin_image service that handles menu images / promo catalogs.
const API_BASE = env.PUBLIC_POST_IMAGE;
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const name = formData.get('name') as string;
const country = (formData.get('country') as string) || 'tha';
const start = formData.get('start') as string;
// 'NONE' means open-ended (no end date).
const end = (formData.get('end') as string) || 'NONE';
const machineNumbers = (formData.get('machine_numbers') as string) || '';
const brewingDuration = (formData.get('brewing_duration') as string) || '1';
const video = formData.get('video') as File;
const brewingVideo = formData.get('brewing_video') as File;
const brewingTxt = formData.get('brewing_txt') as File;
const brewingTxtEn = formData.get('brewing_txt_en') as File;
if (
!uid ||
!displayName ||
!email ||
!name ||
!start ||
!video ||
!brewingVideo ||
!brewingTxt ||
!brewingTxtEn
) {
throw error(400, 'Missing required fields');
}
const endpoint =
`${API_BASE}/video/mainpage/${encodeURIComponent(uid)}` +
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
console.log('[Video MainPage Proxy] Endpoint:', endpoint, 'name:', name);
const upstream = new FormData();
upstream.append('name', name);
upstream.append('country', country);
upstream.append('start', start);
upstream.append('end', end);
upstream.append('machine_numbers', machineNumbers);
upstream.append('brewing_duration', brewingDuration);
upstream.append('video', video);
upstream.append('brewing_video', brewingVideo);
upstream.append('brewing_txt', brewingTxt);
upstream.append('brewing_txt_en', brewingTxtEn);
const response = await fetch(endpoint, {
method: 'POST',
body: upstream
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Add main-page video failed');
}
return json(await response.json());
} catch (err) {
console.error('[Video MainPage 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

@ -1,26 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_POST_IMAGE;
// List a country's main-page videos (managed + read-only). POST because the Kong
// route fronting taobin-image is POST-only.
export const POST: RequestHandler = async ({ url }) => {
try {
const country = (url.searchParams.get('country') || 'tha').toLowerCase();
const response = await fetch(
`${API_BASE}/video/mainpage/list/${encodeURIComponent(country)}`,
{ method: 'POST' }
);
if (!response.ok) {
const e = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, e.detail || 'List main-page videos failed');
}
return json(await response.json());
} catch (err) {
console.error('[Video MainPage List 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

@ -1,56 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_POST_IMAGE;
// Edit a web-managed main-page video: change date range and/or replace the .mp4.
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const slug = formData.get('slug') as string;
const uid = formData.get('uid') as string;
const displayName = formData.get('displayName') as string;
const email = formData.get('email') as string;
const country = (formData.get('country') as string) || 'tha';
const start = formData.get('start') as string;
const end = (formData.get('end') as string) || 'NONE';
const name = formData.get('name') as string | null;
const brewingDuration = formData.get('brewing_duration') as string | null;
const video = formData.get('video') as File | null;
const brewingVideo = formData.get('brewing_video') as File | null;
const brewingTxt = formData.get('brewing_txt') as File | null;
const brewingTxtEn = formData.get('brewing_txt_en') as File | null;
if (!slug || !uid || !displayName || !email || !start) {
throw error(400, 'Missing required fields');
}
const endpoint =
`${API_BASE}/video/mainpage/update/${encodeURIComponent(slug)}/${encodeURIComponent(uid)}` +
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
const upstream = new FormData();
upstream.append('country', country);
upstream.append('start', start);
upstream.append('end', end);
if (name) upstream.append('name', name);
if (brewingDuration) upstream.append('brewing_duration', brewingDuration);
if (video) upstream.append('video', video);
if (brewingVideo) upstream.append('brewing_video', brewingVideo);
if (brewingTxt) upstream.append('brewing_txt', brewingTxt);
if (brewingTxtEn) upstream.append('brewing_txt_en', brewingTxtEn);
const response = await fetch(endpoint, { method: 'POST', body: upstream });
if (!response.ok) {
const e = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, e.detail || 'Update main-page video failed');
}
return json(await response.json());
} catch (err) {
console.error('[Video MainPage Update 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

@ -9,7 +9,7 @@ export default defineConfig({
noExternal: ['@dnd-kit/core', '@dnd-kit/sortable']
},
optimizeDeps: {
include: ['@xterm/xterm', 'xterm-addon-fit', 'xterm-addon-search']
include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search']
},
test: {
expect: { requireAssertions: true },