Compare commits
11 commits
270faf6b34
...
3b6bd1c9f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6bd1c9f7 | ||
|
|
3625712678 | ||
|
|
7f2548b9a8 | ||
| 7a406fd409 | |||
| 47ee23777d | |||
|
|
4f464a8513 | ||
|
|
f0619c5a10 | ||
| 6011b92b7b | |||
| d60a9af6cb | |||
| b5e0705f79 | |||
| 6a2f4e5945 |
82 changed files with 8394 additions and 1380 deletions
18
bun.lock
18
bun.lock
|
|
@ -11,6 +11,7 @@
|
|||
"@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",
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"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",
|
||||
|
|
@ -54,7 +56,7 @@
|
|||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"storybook": "^10.3.5",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte": "^5.56.3",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"svelte-sonner": "^1.1.0",
|
||||
|
|
@ -504,6 +506,8 @@
|
|||
|
||||
"@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=="],
|
||||
|
|
@ -834,7 +838,7 @@
|
|||
|
||||
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
|
|
@ -862,7 +866,7 @@
|
|||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"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": ["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-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=="],
|
||||
|
||||
|
|
@ -1040,9 +1044,15 @@
|
|||
|
||||
"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/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/@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-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { XIcon } from '@lucide/svelte/icons';
|
||||
|
||||
export interface AnnouncementPayload {
|
||||
interface AnnouncementPayload {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
message: string;
|
||||
|
|
@ -84,10 +84,11 @@
|
|||
{#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"
|
||||
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"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
// Close on backdrop click
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
|
|
@ -109,7 +110,7 @@
|
|||
class:scale-95={!animating}
|
||||
>
|
||||
<!-- Colored top border accent -->
|
||||
<div class="h-1.5 w-full {currentStyles().border}" />
|
||||
<div class="h-1.5 w-full {currentStyles().border}"></div>
|
||||
|
||||
<div class="p-6 sm:p-8">
|
||||
<!-- Close button -->
|
||||
|
|
|
|||
333
src/lib/components/RemoteShellConfirmDialog.svelte
Normal file
333
src/lib/components/RemoteShellConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
remoteShellRequest,
|
||||
confirmRemoteShell,
|
||||
rejectRemoteShell
|
||||
} from '$lib/core/stores/remoteShellStore';
|
||||
import type { RemoteShellRequest } from '$lib/core/stores/remoteShellStore';
|
||||
import {
|
||||
XIcon,
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
LoaderCircleIcon,
|
||||
TerminalIcon,
|
||||
ClockIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let current = $state<RemoteShellRequest | null>(null);
|
||||
let animating = $state(false);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
const req = current;
|
||||
if (req?.status === 'pending') {
|
||||
rejectRemoteShell();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
unsub = remoteShellRequest.subscribe((req) => {
|
||||
if (req) {
|
||||
current = req;
|
||||
requestAnimationFrame(() => {
|
||||
animating = true;
|
||||
});
|
||||
} else {
|
||||
animating = false;
|
||||
setTimeout(() => {
|
||||
current = null;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
// if (window) window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsub?.();
|
||||
// if (window) window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
function formatTimestamp(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if current}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9998] flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remote-shell-title"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && current?.status === 'pending') {
|
||||
rejectRemoteShell();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- 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}
|
||||
>
|
||||
<!-- Status accent border -->
|
||||
<div
|
||||
class="h-1.5 w-full"
|
||||
class:bg-amber-500={current.status === 'pending'}
|
||||
class:bg-blue-500={current.status === 'executing'}
|
||||
class:bg-green-500={current.status === 'completed' && !current.timedOut}
|
||||
class:bg-orange-500={current.timedOut}
|
||||
class:bg-red-500={(current.status === 'rejected' || current.status === 'failed') &&
|
||||
!current.timedOut}
|
||||
></div>
|
||||
|
||||
<div class="p-6 sm:p-8">
|
||||
<!-- Close button (only when not pending) -->
|
||||
{#if current.status !== 'pending' && current.status !== 'executing'}
|
||||
<button
|
||||
onclick={() => remoteShellRequest.set(null)}
|
||||
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"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Status icon + title -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
{#if current.status === 'pending'}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<AlertTriangleIcon size={22} class="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="remote-shell-title"
|
||||
class="text-lg font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Remote Shell Command
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
A remote user wants to run a command on your device
|
||||
</p>
|
||||
</div>
|
||||
{:else if current.status === 'executing'}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50"
|
||||
>
|
||||
<LoaderCircleIcon size={22} class="animate-spin text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="remote-shell-title"
|
||||
class="text-lg font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Executing Command...
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Running on your connected Android device
|
||||
{#if current.timeoutSeconds}
|
||||
· auto-cancels after {current.timeoutSeconds}s
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else if current.status === 'completed' && !current.timedOut}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50"
|
||||
>
|
||||
<CheckCircle2Icon size={22} class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="remote-shell-title"
|
||||
class="text-lg font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Command Completed
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Output sent back to server
|
||||
</p>
|
||||
</div>
|
||||
{:else if current.timedOut}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/50"
|
||||
>
|
||||
<ClockIcon size={22} class="text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="remote-shell-title"
|
||||
class="text-lg font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
Command Timed Out
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Aborted after {current.timeoutSeconds ?? '?'}s — partial output sent back
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"
|
||||
>
|
||||
<XIcon size={22} class="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
id="remote-shell-title"
|
||||
class="text-lg font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
{current.status === 'rejected' ? 'Command Rejected' : 'Command Failed'}
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{current.status === 'rejected'
|
||||
? 'You chose not to run this command'
|
||||
: 'An error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dangerous command warning -->
|
||||
{#if current.dangerous}
|
||||
<div class="mb-4 rounded-lg border-2 border-red-500 bg-red-50 p-4 dark:border-red-600 dark:bg-red-900/30">
|
||||
<p
|
||||
class="mb-1 text-xs font-semibold tracking-wider text-red-600 uppercase dark:text-red-400"
|
||||
>
|
||||
<AlertTriangleIcon size={14} class="mr-1 inline" />
|
||||
Security Warning
|
||||
</p>
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
Command requested from server, this will not run unless you accepted.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Purpose declaration -->
|
||||
{#if current.purposeDeclaration}
|
||||
<div class="mb-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800/50">
|
||||
<p
|
||||
class="mb-1 text-xs font-semibold tracking-wider text-neutral-500 uppercase dark:text-neutral-400"
|
||||
>
|
||||
Purpose
|
||||
</p>
|
||||
<p class="text-sm whitespace-pre-wrap text-neutral-800 dark:text-neutral-200">
|
||||
{current.purposeDeclaration}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Command (monospace block) -->
|
||||
<div class="mb-4 rounded-lg bg-neutral-900 p-4 dark:bg-black">
|
||||
<p class="mb-2 text-xs font-semibold tracking-wider text-neutral-400 uppercase">
|
||||
<TerminalIcon size={14} class="mr-1 inline" />
|
||||
Command
|
||||
{#if current.timeoutSeconds}
|
||||
<span
|
||||
class="ml-3 inline-flex items-center gap-1 rounded bg-neutral-700 px-2 py-0.5 text-amber-300"
|
||||
>
|
||||
<ClockIcon size={11} />
|
||||
Timeout: {current.timeoutSeconds}s
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<pre
|
||||
class="max-h-40 overflow-x-auto overflow-y-auto font-mono text-sm break-all whitespace-pre-wrap text-green-400">{current.commandInput}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Requestor Info -->
|
||||
{#if current.userInfo}
|
||||
<div class="mb-4 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span class="font-semibold">Requested by:</span>
|
||||
{current.userInfo.displayName ??
|
||||
current.userInfo.email ??
|
||||
current.userInfo.uid ??
|
||||
'Unknown'}
|
||||
{#if current.userInfo.email}
|
||||
<span class="mx-1">·</span>
|
||||
{current.userInfo.email}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Output / Error (shown when done) -->
|
||||
{#if (current.status === 'completed' || current.status === 'failed') && current.output}
|
||||
<div
|
||||
class="mb-4 max-h-48 overflow-y-auto rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800/50"
|
||||
>
|
||||
<p
|
||||
class="mb-1 text-xs font-semibold tracking-wider text-neutral-500 uppercase dark:text-neutral-400"
|
||||
>
|
||||
Output
|
||||
</p>
|
||||
<pre
|
||||
class="font-mono text-sm break-all whitespace-pre-wrap text-neutral-700 dark:text-neutral-300">{current.output}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current.status === 'failed' && current.error}
|
||||
<div class="mb-4 max-h-32 overflow-y-auto rounded-lg bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p
|
||||
class="mb-1 text-xs font-semibold tracking-wider text-red-600 uppercase dark:text-red-400"
|
||||
>
|
||||
Error
|
||||
</p>
|
||||
<pre
|
||||
class="font-mono text-sm break-all whitespace-pre-wrap text-red-700 dark:text-red-300">{current.error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="mb-5 text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{formatTimestamp(current.timestamp)}
|
||||
{#if current.exitCode !== undefined}
|
||||
<span class="ml-3">
|
||||
Exit code: <span class="font-mono">{current.exitCode}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
{#if current.status === 'pending'}
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={rejectRemoteShell}
|
||||
class="inline-flex items-center justify-center rounded-lg border border-neutral-300 bg-white px-5 py-2.5 text-sm font-semibold text-neutral-700 shadow-sm transition-colors hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:outline-none dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onclick={confirmRemoteShell}
|
||||
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"
|
||||
>
|
||||
Confirm & Run
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current.status === 'executing'}
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
<LoaderCircleIcon size={16} class="animate-spin" />
|
||||
Running...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
|
||||
|
|
@ -41,7 +42,7 @@
|
|||
// await adb.executeCmd('reboot');
|
||||
await adb.disconnect();
|
||||
} catch (e) {
|
||||
console.error('error disconnect device while logging out', e);
|
||||
logger.error('error disconnect device while logging out', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +54,7 @@
|
|||
try {
|
||||
socket?.close(1000, 'logout');
|
||||
} catch (e) {
|
||||
console.log('error on disconnect', e);
|
||||
logger.info('error on disconnect', e);
|
||||
}
|
||||
|
||||
socketStore.set(null);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
import { onDestroy, type ComponentProps } from 'svelte';
|
||||
import { asset } from '$app/paths';
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
PlusCircle,
|
||||
ImageUp,
|
||||
Video,
|
||||
MonitorPlay,
|
||||
Sun,
|
||||
Moon
|
||||
} from '@lucide/svelte/icons';
|
||||
|
|
@ -125,6 +127,12 @@
|
|||
url: '/tools/adv-upload',
|
||||
icon: Video,
|
||||
requirePerm: ''
|
||||
},
|
||||
{
|
||||
title: 'Main & Brewing Video',
|
||||
url: '/tools/video-mainpage',
|
||||
icon: MonitorPlay,
|
||||
requirePerm: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -142,6 +150,12 @@
|
|||
url: '/departments',
|
||||
icon: DollarSign,
|
||||
requirePerm: 'document.write.*'
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
url: '/departments',
|
||||
icon: FileSpreadsheet,
|
||||
requirePerm: 'document.write.*'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -167,7 +181,7 @@
|
|||
isAdmin = result;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error checking admin status:', e);
|
||||
logger.error('Error checking admin status:', e);
|
||||
isAdmin = false;
|
||||
});
|
||||
} else {
|
||||
|
|
@ -235,7 +249,13 @@
|
|||
onclick={(e) => {
|
||||
if (nav.title === 'Sheet') {
|
||||
e.preventDefault();
|
||||
referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
|
||||
referenceFromPage.set(
|
||||
sub.title === 'PriceSlot'
|
||||
? 'priceslot'
|
||||
: sub.title === 'Price'
|
||||
? 'price'
|
||||
: 'sheet'
|
||||
);
|
||||
goto(sub.url);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { Button, type ButtonVariant } from './ui/button';
|
||||
import * as Card from './ui/card/index';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
|
|
@ -17,7 +18,8 @@
|
|||
AdbDaemonWebUsbDeviceManager
|
||||
} from '@yume-chan/adb-daemon-webusb';
|
||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||
import { file } from 'zod/mini';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
|
|
@ -158,19 +160,19 @@
|
|||
|
||||
if (result) {
|
||||
if (result === '') {
|
||||
console.log('push pull not ok, get empty');
|
||||
logger.info('push pull not ok, get empty');
|
||||
} else {
|
||||
console.log('push pull ok', result);
|
||||
logger.info('push pull ok', result);
|
||||
}
|
||||
} else {
|
||||
console.log('push pull not ok', result);
|
||||
logger.info('push pull not ok', result);
|
||||
}
|
||||
|
||||
// clean file
|
||||
await adb.executeCmd('rm /sdcard/coffeevending/test.json');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('test push file failed', error);
|
||||
logger.info('test push file failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +202,7 @@
|
|||
} catch (ignored) {}
|
||||
}
|
||||
|
||||
console.log('error on quick adb: ', e);
|
||||
logger.info('error on quick adb: ', e);
|
||||
toast.error(`Machine Connection Error`, {
|
||||
description: e.toString()
|
||||
});
|
||||
|
|
@ -246,7 +248,7 @@
|
|||
connectDeviceOk = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('error on quick adb: ', e);
|
||||
logger.info('error on quick adb: ', e);
|
||||
toast.error(`Machine Connection Error`, {
|
||||
description: e.toString()
|
||||
});
|
||||
|
|
@ -279,12 +281,12 @@
|
|||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('check stored error', e);
|
||||
logger.info('check stored error', e);
|
||||
}
|
||||
|
||||
hasStoredDevice = devices.length > 0 && hasKeys;
|
||||
} catch (e) {
|
||||
console.error('check stored error', e);
|
||||
logger.error('check stored error', e);
|
||||
hasStoredDevice = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +334,7 @@
|
|||
return false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('error on auto connect adb: ', e);
|
||||
logger.info('error on auto connect adb: ', e);
|
||||
toast.error(`Machine Connection Error`, {
|
||||
description: e.toString()
|
||||
});
|
||||
|
|
@ -345,8 +347,10 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
// update every 1s
|
||||
setInterval(async function () {
|
||||
// Poll device connection status every 1s so the UI button reflects the
|
||||
// current state. The interval is saved and cleaned up on destroy to
|
||||
// prevent leaks when this component is mounted/unmounted.
|
||||
const _connPollInterval = setInterval(async function () {
|
||||
checkDeviceConnection();
|
||||
}, 1000);
|
||||
|
||||
|
|
@ -354,6 +358,10 @@
|
|||
await checkStoredCredentials();
|
||||
if (!connectDeviceOk && !adb.getAdbInstance()) await tryAutoConnect();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(_connPollInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { messages as WSMsg } from '$lib/core/handlers/messageHandler';
|
||||
import * as Card from './ui/card/index';
|
||||
import { ScrollArea } from './ui/scroll-area/index';
|
||||
|
|
@ -42,7 +43,7 @@
|
|||
unsubWebSocketMsg();
|
||||
});
|
||||
|
||||
console.log('current department: ', get(departmentStore));
|
||||
logger.info('current department: ', get(departmentStore));
|
||||
</script>
|
||||
|
||||
<div class="grid grid-flow-row-dense grid-cols-3 grid-rows-3">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { Button, type ButtonVariant } from './ui/button';
|
||||
import * as Card from './ui/card/index';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
let infoMap: any | undefined = $state();
|
||||
|
||||
let unsubMachineInfo = machineInfoStore.subscribe((mInfo) => {
|
||||
console.log('get info', JSON.stringify(mInfo));
|
||||
logger.info('get info', JSON.stringify(mInfo));
|
||||
// check status web socket
|
||||
|
||||
currentMachineInfo = mInfo;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
// {
|
||||
// "MixOrder": 0,
|
||||
// "StringParam": "",
|
||||
|
|
@ -21,7 +22,8 @@ import RecipelistValue from './recipelist-value.svelte';
|
|||
import { DragHandle } from './recipelist-table.svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import RecipelistMatSelect from './recipelist-mat-select.svelte';
|
||||
import { recipeDataEvent } from '$lib/core/stores/recipeStore';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { RECIPE_EVENTS } from './recipe-editor-events';
|
||||
import RecipelistValueEditor from './recipelist-value-editor.svelte';
|
||||
|
||||
export type RecipelistMaterial = {
|
||||
|
|
@ -92,10 +94,9 @@ export const columns: ColumnDef<RecipelistMaterial>[] = [
|
|||
currentMat: row.original.material_id,
|
||||
onMatChange: (value: any) => {
|
||||
row.original.material_id = value;
|
||||
console.log('change mat', value);
|
||||
recipeDataEvent.set({
|
||||
event_type: 'mat_change',
|
||||
payload: value,
|
||||
logger.info('change mat', value);
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.MAT_CHANGE, {
|
||||
mat_id: value,
|
||||
index: row.original.id
|
||||
});
|
||||
row.toggleSelected(row.original.is_use);
|
||||
|
|
@ -112,10 +113,9 @@ export const columns: ColumnDef<RecipelistMaterial>[] = [
|
|||
row_uid: row.original.id,
|
||||
mat_id: row.original.material_id,
|
||||
onEditValue: (changes: any) => {
|
||||
// console.log('triggered on edit value', changes);
|
||||
// logger.info('triggered on edit value', changes);
|
||||
|
||||
recipeDataEvent.set({
|
||||
event_type: 'edit_change_value_rpl',
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.EDIT_CHANGE_VALUE_RPL, {
|
||||
payload: JSON.parse(changes),
|
||||
index: row.original.id
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as Tabs from '$lib/components/ui/tabs/index';
|
||||
import * as Card from '$lib/components/ui/card/index';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
let materialSnapshot: any = $state();
|
||||
let machineInfoSnapshot: any = $state();
|
||||
|
||||
let recipeListMatState: RecipelistMaterial[] = $state([]);
|
||||
let activeTab = $state('info');
|
||||
|
||||
let recipeListOriginal: RecipelistMaterial[] = $state([]);
|
||||
|
||||
let toppingSlotState: any = $state([]);
|
||||
|
|
@ -63,61 +65,52 @@
|
|||
|
||||
const recipeDetailDispatch = createEventDispatcher();
|
||||
|
||||
function remappingToColumn(data: any[]): RecipelistMaterial[] {
|
||||
let ret: RecipelistMaterial[] = [];
|
||||
// expect recipelist
|
||||
if (materialSnapshot) {
|
||||
let d_cnt = 0;
|
||||
for (let rpl of data) {
|
||||
let mat = materialSnapshot.filter(
|
||||
(x: any) => x['id'].toString() === rpl['materialPathId'].toString()
|
||||
)[0];
|
||||
/** Map-based material lookup — O(1) instead of O(n) Array.filter per row. */
|
||||
function remappingToColumn(data: any[] | undefined, materials: any[] | undefined): RecipelistMaterial[] {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// console.log('mat filter get', Object(mat), Object.keys(mat));
|
||||
|
||||
let name = mat ? mat['materialOtherName'] : rpl['materialPathId'];
|
||||
|
||||
if (rpl['materialPathId'] === 0) {
|
||||
name = '-';
|
||||
}
|
||||
|
||||
// let gen_id = generateRowId();
|
||||
|
||||
// console.log(`generated for ${rpl['materialPathId']} = ${gen_id}`);
|
||||
|
||||
ret.push({
|
||||
id: d_cnt,
|
||||
material_id: `${name} (${rpl['materialPathId']})`,
|
||||
is_use: rpl['isUse'],
|
||||
values: {
|
||||
string_param: rpl['StringParam'],
|
||||
mix_order: rpl['MixOrder'],
|
||||
stir_time: rpl['stirTime'],
|
||||
feed: {
|
||||
pattern: rpl['FeedPattern'],
|
||||
parameter: rpl['FeedParameter']
|
||||
},
|
||||
powder: {
|
||||
gram: rpl['powderGram'],
|
||||
time: rpl['powderTime']
|
||||
},
|
||||
syrup: {
|
||||
gram: rpl['syrupGram'],
|
||||
time: rpl['syrupTime']
|
||||
},
|
||||
water: {
|
||||
cold: rpl['waterCold'],
|
||||
yield: rpl['waterYield']
|
||||
}
|
||||
}
|
||||
});
|
||||
d_cnt++;
|
||||
// Build Map<materialId_str, material> once for O(1) lookups
|
||||
const matMap = new Map<string, any>();
|
||||
if (materials) {
|
||||
for (const mat of materials) {
|
||||
matMap.set(mat.id.toString(), mat);
|
||||
}
|
||||
}
|
||||
|
||||
const ret: RecipelistMaterial[] = [];
|
||||
for (let d_cnt = 0; d_cnt < data.length; d_cnt++) {
|
||||
const rpl = data[d_cnt];
|
||||
const mat = matMap.get(rpl['materialPathId'].toString());
|
||||
|
||||
let name = mat ? mat['materialOtherName'] : rpl['materialPathId'];
|
||||
if (rpl['materialPathId'] === 0) {
|
||||
name = '-';
|
||||
}
|
||||
|
||||
ret.push({
|
||||
id: d_cnt,
|
||||
material_id: `${name} (${rpl['materialPathId']})`,
|
||||
is_use: rpl['isUse'],
|
||||
values: {
|
||||
string_param: rpl['StringParam'],
|
||||
mix_order: rpl['MixOrder'],
|
||||
stir_time: rpl['stirTime'],
|
||||
feed: { pattern: rpl['FeedPattern'], parameter: rpl['FeedParameter'] },
|
||||
powder: { gram: rpl['powderGram'], time: rpl['powderTime'] },
|
||||
syrup: { gram: rpl['syrupGram'], time: rpl['syrupTime'] },
|
||||
water: { cold: rpl['waterCold'], yield: rpl['waterYield'] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/** Reactively build recipe-list table data when inputs change. */
|
||||
const recipeListMatState: RecipelistMaterial[] = $derived(
|
||||
remappingToColumn(recipeData?.recipes, materialSnapshot)
|
||||
);
|
||||
|
||||
async function getCurrentQueue() {
|
||||
let inst = adb.getAdbInstance();
|
||||
if (inst) {
|
||||
|
|
@ -129,7 +122,7 @@
|
|||
}
|
||||
|
||||
let current_brewing = await adb.pull(currentRecipePath);
|
||||
// console.log(`current brewing queue: ${current_brewing}`);
|
||||
// logger.info(`current brewing queue: ${current_brewing}`);
|
||||
if (current_brewing === '') {
|
||||
current_brewing = '{}';
|
||||
}
|
||||
|
|
@ -159,7 +152,7 @@
|
|||
async function sendTriggerBrewNow() {
|
||||
// check queue ready
|
||||
// let currentBrewingQueue = await getCurrentQueue();
|
||||
// console.log('checking queue ... ', Object.keys(currentBrewingQueue).length);
|
||||
// logger.info('checking queue ... ', Object.keys(currentBrewingQueue).length);
|
||||
|
||||
await sendToAndroid({
|
||||
type: 'brew_prep',
|
||||
|
|
@ -176,10 +169,10 @@
|
|||
//
|
||||
let inst = adb.getAdbInstance();
|
||||
if (inst) {
|
||||
console.log('check adb writer', isAdbWriterAvailable());
|
||||
logger.info('check adb writer', isAdbWriterAvailable());
|
||||
recipeDetailDispatch('brewNow');
|
||||
} else {
|
||||
console.log('result check fail');
|
||||
logger.info('result check fail');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +193,7 @@
|
|||
}
|
||||
|
||||
async function checkChanges(productCode: string, original: any) {
|
||||
// console.log('old', original, 'updated', recipeListMatState);
|
||||
// logger.info('old', original, 'updated', recipeListMatState);
|
||||
if (recipeListOriginal.length == 0) {
|
||||
recipeListOriginal = original;
|
||||
}
|
||||
|
|
@ -246,8 +239,6 @@
|
|||
} else if (refPage == 'brew') {
|
||||
materialSnapshot = get(materialFromMachineQuery);
|
||||
}
|
||||
|
||||
recipeListMatState = remappingToColumn(recipeData['recipes']);
|
||||
toppingSlotState = recipeData['ToppingSet'];
|
||||
|
||||
latestRecipeToppingData.set(toppingSlotState);
|
||||
|
|
@ -257,12 +248,12 @@
|
|||
let currentPricesFromServer = get(priceRecipeData);
|
||||
let currentPrice = currentPricesFromServer[productCode] ?? '';
|
||||
|
||||
console.log(currentPricesFromServer);
|
||||
logger.info(currentPricesFromServer);
|
||||
|
||||
if (currentPrice != '') {
|
||||
// has price
|
||||
let priceParts = currentPrice.split(',');
|
||||
console.log('price part', priceParts);
|
||||
logger.info('price part', priceParts);
|
||||
try {
|
||||
let price = parseInt(priceParts[0]);
|
||||
let extraParam: string = priceParts[1] ?? '';
|
||||
|
|
@ -271,7 +262,7 @@
|
|||
isMenuHideByPrice = extraParam.includes('hide=true');
|
||||
}
|
||||
|
||||
console.log('hide = ', extraParam);
|
||||
logger.info('hide = ', extraParam);
|
||||
|
||||
menuCurrentPrice = price;
|
||||
|
||||
|
|
@ -304,7 +295,7 @@
|
|||
<!-- Menu Status -->
|
||||
|
||||
<div class="-mb-4 flex w-full flex-col gap-6">
|
||||
<Tabs.Root value="info">
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<div class="flex flex-row justify-between">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="info">Info</Tabs.Trigger>
|
||||
|
|
@ -434,25 +425,28 @@
|
|||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="details">
|
||||
<!-- hide by machine state -->
|
||||
<!-- Lazy-mount: RecipelistTable + sub-components only render when user clicks this tab -->
|
||||
{#if activeTab === 'details'}
|
||||
<!-- hide by machine state -->
|
||||
|
||||
{#if $machineInfoStore?.status == 'IDLE' || $machineInfoStore?.status == '' || refPage == 'overview'}
|
||||
<RecipelistTable
|
||||
data={recipeListMatState}
|
||||
{columns}
|
||||
onStateChange={checkChanges}
|
||||
{productCode}
|
||||
/>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<div class="grid h-[60vh] w-full place-items-center rounded-md border">
|
||||
<div class="m-4 flex flex-row gap-2">
|
||||
<Spinner />Machine is working. Please wait for a moment.
|
||||
{#if $machineInfoStore?.status == 'IDLE' || $machineInfoStore?.status == '' || refPage == 'overview'}
|
||||
<RecipelistTable
|
||||
data={recipeListMatState}
|
||||
{columns}
|
||||
onStateChange={checkChanges}
|
||||
{productCode}
|
||||
/>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<div class="grid h-[60vh] w-full place-items-center rounded-md border">
|
||||
<div class="m-4 flex flex-row gap-2">
|
||||
<Spinner />Machine is working. Please wait for a moment.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
|
|
|||
94
src/lib/components/recipe-details/recipe-editor-events.ts
Normal file
94
src/lib/components/recipe-details/recipe-editor-events.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Recipe Editor Event Types
|
||||
*
|
||||
* Typed event definitions for the recipe-editor domain.
|
||||
* Replaces the `recipeDataEvent` writable store pattern with GlobalEventBus.
|
||||
*
|
||||
* Event name convention: `recipe:${action}`
|
||||
*/
|
||||
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
|
||||
// ─── Event name constants ───────────────────────────────────────────
|
||||
export const RECIPE_EVENTS = {
|
||||
MAT_CHANGE: 'recipe:mat_change',
|
||||
EDIT_MAT_FIELD: 'recipe:edit_mat_field',
|
||||
EDIT_MAT_FIELD_PREP: 'recipe:edit_mat_field_prep',
|
||||
SAVE_MAT_FIELD: 'recipe:save_mat_field',
|
||||
EDIT_CHANGE_VALUE_RPL: 'recipe:edit_change_value_rpl',
|
||||
REVERT_CHANGE: 'recipe:revert_change'
|
||||
} as const;
|
||||
|
||||
// ─── Payload types ──────────────────────────────────────────────────
|
||||
|
||||
export interface EditMatFieldPrepPayload {
|
||||
mat_id: number;
|
||||
mat_type: string;
|
||||
mat_name: string;
|
||||
params: Record<string, string>;
|
||||
toppings: any[];
|
||||
current_topping_group: any;
|
||||
current_topping_list: any[];
|
||||
has_mix_ord: boolean;
|
||||
feed: { pattern: number; parameter: number };
|
||||
water: { cold: number; yield: number };
|
||||
powder: { gram: number; time: number };
|
||||
syrup: { gram: number; time: number };
|
||||
stir_time: number;
|
||||
}
|
||||
|
||||
export interface SaveMatFieldPayload {
|
||||
source: EditMatFieldPrepPayload;
|
||||
change: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EditChangeValueRplPayload extends SaveMatFieldPayload {
|
||||
current_toppings: any[];
|
||||
}
|
||||
|
||||
// ─── Module-level pending save data ────────────────────────────────
|
||||
// Replaces direct `get(recipeDataEvent)` reads in recipe-editor-dialog.svelte.
|
||||
// The recipe-dialog reads the latest pending save data synchronously;
|
||||
// GlobalEventBus has no "get latest value" so we store it here.
|
||||
|
||||
interface PendingSaveData {
|
||||
change: Record<string, any>;
|
||||
payload: SaveMatFieldPayload & { current_toppings?: any[] };
|
||||
index: number;
|
||||
}
|
||||
|
||||
let _latestPendingSave: PendingSaveData | null = null;
|
||||
|
||||
export function setPendingSaveData(data: PendingSaveData): void {
|
||||
_latestPendingSave = data;
|
||||
}
|
||||
|
||||
export function getPendingSaveData(): PendingSaveData | null {
|
||||
return _latestPendingSave;
|
||||
}
|
||||
|
||||
export function clearPendingSaveData(): void {
|
||||
_latestPendingSave = null;
|
||||
}
|
||||
|
||||
// ─── Typed emit helpers ────────────────────────────────────────────
|
||||
|
||||
export function emitMatChange(matId: string, index: number): void {
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.MAT_CHANGE, { mat_id: matId, index });
|
||||
}
|
||||
|
||||
export function emitEditMatField(index: number): void {
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.EDIT_MAT_FIELD, { index });
|
||||
}
|
||||
|
||||
export function emitEditMatFieldPrep(payload: EditMatFieldPrepPayload, index: number): void {
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.EDIT_MAT_FIELD_PREP, { payload, index });
|
||||
}
|
||||
|
||||
export function emitSaveMatField(payload: SaveMatFieldPayload, index: number): void {
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.SAVE_MAT_FIELD, { payload, index });
|
||||
}
|
||||
|
||||
export function emitRevertChange(index: number): void {
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.REVERT_CHANGE, { index });
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '../ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
if (refPage === 'brew') allMatData = get(materialFromMachineQuery);
|
||||
else if (refPage === 'overview') allMatData = get(materialFromServerQuery);
|
||||
|
||||
// console.log('all mat data', JSON.stringify(allMatData));
|
||||
// logger.info('all mat data', JSON.stringify(allMatData));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script module>
|
||||
<script module>import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
export { DragHandle };
|
||||
</script>
|
||||
|
||||
|
|
@ -76,14 +77,14 @@
|
|||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
onStateChange: async (updater) => {
|
||||
console.log('table state change', data);
|
||||
logger.info('table state change', data);
|
||||
await onStateChange(
|
||||
productCode,
|
||||
table.getRowModel().rows.map((x) => x.original)
|
||||
);
|
||||
},
|
||||
onSortingChange: async (updater) => {
|
||||
console.log('triggering sorting');
|
||||
logger.info('triggering sorting');
|
||||
if (typeof updater === 'function') {
|
||||
sorting = updater(sorting);
|
||||
} else {
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
rowSelection = updater(rowSelection);
|
||||
let rows = table.getRowModel().rows;
|
||||
|
||||
// console.log('state size', data, rows);
|
||||
// logger.info('state size', data, rows);
|
||||
} else {
|
||||
rowSelection = updater;
|
||||
}
|
||||
|
|
@ -126,10 +127,13 @@
|
|||
// @ts-expect-error @dnd-kit/adbstract types are botched atm
|
||||
RestrictToVerticalAxis
|
||||
]}
|
||||
onDragEnd={(e) => {
|
||||
onDragEnd={async (e) => {
|
||||
// snap
|
||||
data = move(data as any, e as any);
|
||||
//
|
||||
// Notify parent of reorder so changes are saved
|
||||
if (onStateChange) {
|
||||
await onStateChange(productCode, data);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ScrollArea class="h-[60vh] w-full rounded-md border" type="always">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { CupSodaIcon, PencilIcon, ChevronRightIcon, StarsIcon } from '@lucide/svelte/icons';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import * as Select from '$lib/components/ui/select/index';
|
||||
|
|
@ -10,17 +11,18 @@
|
|||
|
||||
import {
|
||||
latestRecipeToppingData,
|
||||
recipeDataEvent,
|
||||
toppingGroupFromServerQuery,
|
||||
toppingListFromServerQuery,
|
||||
toppingGroupFromMachineQuery,
|
||||
toppingListFromMachineQuery,
|
||||
referenceFromPage
|
||||
} from '$lib/core/stores/recipeStore';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { get } from 'svelte/store';
|
||||
import { actionReport, ValueEvent } from './value_event';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { RECIPE_EVENTS, emitEditMatField, emitSaveMatField } from './recipe-editor-events';
|
||||
import ScrollArea from '../ui/scroll-area/scroll-area.svelte';
|
||||
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
|
|
@ -38,6 +40,8 @@
|
|||
|
||||
let warnUserNotSaveChange = $state(false);
|
||||
|
||||
let unsubEvents: (() => void) | null = null;
|
||||
|
||||
// --------------------------------------------------
|
||||
|
||||
let toggledOpenPowder = $state(false);
|
||||
|
|
@ -81,12 +85,12 @@
|
|||
}
|
||||
|
||||
function handleEvents(event: { event_type: string; payload: any; index: number | undefined }) {
|
||||
// console.log('triggered event', event.event_type, JSON.stringify(event.payload));
|
||||
// logger.info('triggered event', event.event_type, JSON.stringify(event.payload));
|
||||
if (event.event_type === 'edit_mat_field_prep') {
|
||||
// update value, do re-render
|
||||
if (event.payload) {
|
||||
current_editing_data = event.payload;
|
||||
// console.log(`GET requested data: ${JSON.stringify(current_editing_data)}`);
|
||||
// logger.info(`GET requested data: ${JSON.stringify(current_editing_data)}`);
|
||||
|
||||
// default topping
|
||||
if (
|
||||
|
|
@ -124,12 +128,8 @@
|
|||
function requestDataFromDisplay() {
|
||||
// pause show until have data
|
||||
sheetOpenState = false;
|
||||
console.log('sending request edit', row_id);
|
||||
recipeDataEvent.set({
|
||||
event_type: 'edit_mat_field',
|
||||
payload: row_id,
|
||||
index: row_id
|
||||
});
|
||||
logger.info('sending request edit', row_id);
|
||||
emitEditMatField(row_id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (current_editing_data === undefined) {
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
try {
|
||||
curr_val = curr_val[field];
|
||||
} catch (e) {
|
||||
console.error('try get field error: ', e);
|
||||
logger.error('try get field error: ', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,18 +174,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function saveEditingValue() {
|
||||
console.log('saving value ...', value_event_state);
|
||||
function saveEditingValue() {
|
||||
logger.info('saving value ...', value_event_state);
|
||||
if (value_event_state === ValueEvent.EDITED || value_event_state === ValueEvent.SAVED) {
|
||||
let payload = {
|
||||
source: current_editing_data,
|
||||
change: changed_data
|
||||
};
|
||||
recipeDataEvent.set({
|
||||
event_type: 'save_mat_field',
|
||||
payload,
|
||||
index: row_id
|
||||
});
|
||||
emitSaveMatField(payload, row_id);
|
||||
|
||||
value_event_state = ValueEvent.SAVED;
|
||||
|
||||
|
|
@ -208,7 +204,7 @@
|
|||
}
|
||||
|
||||
function handleToppingGroupChange(v: any) {
|
||||
console.log('change topping group');
|
||||
logger.info('change topping group');
|
||||
|
||||
// TODO: clear topping list, otherwise, it will continue to append filter to list by each navigate back and forth
|
||||
|
||||
|
|
@ -226,11 +222,11 @@
|
|||
if (changed_data['toppings'] == undefined || changed_data['toppings'] == null) {
|
||||
changed_data['toppings'] = new Array<any>(toppings_length);
|
||||
|
||||
console.log('filling change topping', JSON.stringify(changed_data));
|
||||
logger.info('filling change topping', JSON.stringify(changed_data));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('current editing data', current_editing_data['toppings']);
|
||||
logger.info('current editing data', current_editing_data['toppings']);
|
||||
|
||||
let idx = getToppingSlotIndex(current_editing_data['mat_id']);
|
||||
|
||||
|
|
@ -238,7 +234,7 @@
|
|||
let current_selection = current_editing_data['toppings'][idx];
|
||||
|
||||
// let default_from_recipe = current_editing_data['toppings'][idx]['ListGroupID'][0];
|
||||
console.log(`Current TG: `, JSON.stringify(current_selection));
|
||||
logger.info(`Current TG: `, JSON.stringify(current_selection));
|
||||
if (current_selection['groupID'] !== undefined || current_selection['groupID'] !== null) {
|
||||
try {
|
||||
changed_data['toppings'][idx] = current_selection;
|
||||
|
|
@ -246,13 +242,13 @@
|
|||
changed_data['toppings'][idx]['defaultIDSelect'] = selected_topping_list_id;
|
||||
changed_data['toppings'][idx]['ListGroupID'][0] = selected_topping_list_id;
|
||||
} catch (topping_group_exception) {
|
||||
console.error('Error on topping group select', topping_group_exception);
|
||||
logger.error('Error on topping group select', topping_group_exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToppingListChange(v: any) {
|
||||
console.log('Topping list chose ');
|
||||
logger.info('Topping list chose ');
|
||||
selected_topping_list_id = v.id;
|
||||
|
||||
// set to edit state even if selected same group again
|
||||
|
|
@ -278,7 +274,7 @@
|
|||
changed_data['toppings'][idx]['defaultIDSelect'] = selected_topping_list_id;
|
||||
changed_data['toppings'][idx]['ListGroupID'][0] = selected_topping_list_id;
|
||||
} catch (topping_list_exception) {
|
||||
console.error('Error on topping group select', topping_list_exception);
|
||||
logger.error('Error on topping group select', topping_list_exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -290,7 +286,7 @@
|
|||
|
||||
onMount(() => {
|
||||
sheetOpenState = false;
|
||||
// console.log('sheet open? ', sheetOpenState);
|
||||
// logger.info('sheet open? ', sheetOpenState);
|
||||
|
||||
let refFrom = get(referenceFromPage);
|
||||
categories = $state.snapshot(
|
||||
|
|
@ -303,11 +299,24 @@
|
|||
// save ref
|
||||
currentRef = refFrom;
|
||||
|
||||
return recipeDataEvent.subscribe((event) => {
|
||||
if (event !== null && event.index !== undefined && event.index === row_id) {
|
||||
handleEvents(event);
|
||||
}
|
||||
});
|
||||
unsubEvents = (() => {
|
||||
const unsub = GlobalEventBus.on(RECIPE_EVENTS.EDIT_MAT_FIELD_PREP, (data: any) => {
|
||||
if (data && data.index === row_id) {
|
||||
handleEvents({
|
||||
event_type: 'edit_mat_field_prep',
|
||||
payload: data.payload,
|
||||
index: data.index
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubEvents) unsubEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { RecipelistMaterial } from './columns';
|
||||
import {
|
||||
|
|
@ -14,7 +15,6 @@
|
|||
import * as Tooltip from '../ui/tooltip/index';
|
||||
import {
|
||||
latestRecipeToppingData,
|
||||
recipeDataEvent,
|
||||
toppingGroupFromServerQuery,
|
||||
toppingListFromServerQuery,
|
||||
toppingGroupFromMachineQuery,
|
||||
|
|
@ -22,6 +22,13 @@
|
|||
referenceFromPage
|
||||
} from '$lib/core/stores/recipeStore';
|
||||
import { get } from 'svelte/store';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import {
|
||||
RECIPE_EVENTS,
|
||||
emitEditMatFieldPrep,
|
||||
setPendingSaveData,
|
||||
clearPendingSaveData
|
||||
} from './recipe-editor-events';
|
||||
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { departmentStore } from '$lib/core/stores/departments';
|
||||
|
|
@ -52,6 +59,15 @@
|
|||
let currentStringParams: any = $state({});
|
||||
let currentMaterialInt: number = $state(0);
|
||||
|
||||
// Local reactive copies of display values so applyChanges() triggers
|
||||
// immediate template re-render (mutating $props() objects directly
|
||||
// does not reliably trigger Svelte 5 deep proxy reactivity).
|
||||
let displayWater = $state<{ cold: number; yield: number }>({ ...water });
|
||||
let displayStirTime = $state<number>(stir_time);
|
||||
let displayFeed = $state<{ pattern: number; parameter: number }>({ ...feed });
|
||||
let displayPowder = $state<{ gram: number; time: number }>({ ...powder });
|
||||
let displaySyrup = $state<{ gram: number; time: number }>({ ...syrup });
|
||||
|
||||
// toppings
|
||||
let currentToppings: any = $state([]);
|
||||
let oldToppings: any = $state(null);
|
||||
|
|
@ -62,6 +78,8 @@
|
|||
let currentToppingNamesOnly = $state('');
|
||||
let oldToppingNamesOnly = $state(null);
|
||||
|
||||
let toppingDataLoading = $state(true);
|
||||
|
||||
let unsubRecipeDataEvent: any;
|
||||
let unsubRecipeTopping: any;
|
||||
|
||||
|
|
@ -85,7 +103,7 @@
|
|||
let key = kv[0];
|
||||
let value = kv[1] ?? '';
|
||||
|
||||
// console.log('key', key, 'value', value);
|
||||
// logger.info('key', key, 'value', value);
|
||||
currentStringParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -94,9 +112,9 @@
|
|||
|
||||
function getPowderSyrupValue() {
|
||||
if (currentMaterialType === 'powder' || currentMaterialType === 'bean') {
|
||||
return powder.gram;
|
||||
return displayPowder.gram;
|
||||
} else if (currentMaterialType === 'syrup') {
|
||||
return syrup.gram;
|
||||
return displaySyrup.gram;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -126,26 +144,36 @@
|
|||
refFrom === 'overview' ? toppingListFromServerQuery : toppingListFromMachineQuery
|
||||
);
|
||||
|
||||
console.log('old topping', oldToppings[getToppingSlot()]);
|
||||
// Guard: bail out if topping slot data is null/undefined (unused slot)
|
||||
if (!current_row_topping) {
|
||||
currentToppingInRow = { name: 'Empty', otherName: 'Empty', id: 0 };
|
||||
selectableToppingInGroup = [{ name: 'Empty', otherName: 'Empty', id: 0 }];
|
||||
currentToppingNamesOnly = '-';
|
||||
oldToppingNamesOnly = null;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('current topping', current_row_topping);
|
||||
logger.info('old topping', oldToppings?.[getToppingSlot()]);
|
||||
|
||||
logger.info('current topping', current_row_topping);
|
||||
oldToppingNamesOnly = null; // reset first
|
||||
if (oldToppings[getToppingSlot()] != current_row_topping) {
|
||||
console.log('detect change on topping row', row_uid);
|
||||
if (oldToppings?.[getToppingSlot()] != null && oldToppings[getToppingSlot()] != current_row_topping) {
|
||||
logger.info('detect change on topping row', row_uid);
|
||||
|
||||
let oldSlot = oldToppings[getToppingSlot()];
|
||||
let groupIDchange =
|
||||
oldToppings[getToppingSlot()]['groupID'] !== current_row_topping['groupID'];
|
||||
oldSlot['groupID'] !== current_row_topping['groupID'];
|
||||
let defaultIDchange =
|
||||
oldToppings[getToppingSlot()]['defaultIDSelect'] !== current_row_topping['defaultIDSelect'];
|
||||
oldSlot['defaultIDSelect'] !== current_row_topping['defaultIDSelect'];
|
||||
|
||||
console.log('group change', groupIDchange, 'default id change', defaultIDchange);
|
||||
logger.info('group change', groupIDchange, 'default id change', defaultIDchange);
|
||||
|
||||
if (!groupIDchange && !defaultIDchange) {
|
||||
oldToppingNamesOnly = null;
|
||||
} else {
|
||||
// has change, display old name
|
||||
let oldGroupData = groupQuery.find(
|
||||
(x: any) => x.groupID.toString() === oldToppings[getToppingSlot()]['groupID']
|
||||
(x: any) => x.groupID.toString() === oldSlot['groupID']
|
||||
);
|
||||
let oldListData = listQuery.filter(
|
||||
(x: any) =>
|
||||
|
|
@ -153,7 +181,7 @@
|
|||
Object.keys(oldGroupData).includes('idInGroup') &&
|
||||
oldGroupData['idInGroup'].split(',').includes(x.id.toString())
|
||||
);
|
||||
oldToppingNamesOnly = oldGroupData['otherName'];
|
||||
oldToppingNamesOnly = oldGroupData?.['otherName'] ?? null;
|
||||
}
|
||||
} else {
|
||||
oldToppingNamesOnly = null;
|
||||
|
|
@ -169,7 +197,7 @@
|
|||
groupData['idInGroup'].split(',').includes(x.id.toString())
|
||||
);
|
||||
|
||||
console.log('topping data', JSON.stringify(groupData), 'list', listData.length);
|
||||
logger.info('topping data', JSON.stringify(groupData), 'list', listData.length);
|
||||
|
||||
currentToppingNamesOnly = groupData ? (groupData['otherName'] ?? '-') : '-';
|
||||
|
||||
|
|
@ -203,15 +231,19 @@
|
|||
}
|
||||
|
||||
function getCurrentSelectedToppingList() {
|
||||
// FIXME: show unknown on preview
|
||||
let slot = getToppingSlot();
|
||||
if (!currentToppings || !currentToppings[slot] || !currentToppings[slot]['ListGroupID']) {
|
||||
return 'Loading...';
|
||||
}
|
||||
|
||||
let listGroupId = currentToppings[slot]['ListGroupID'];
|
||||
let current_selected = selectableToppingInGroup.find(
|
||||
(x) => x.id === currentToppings[getToppingSlot()]['ListGroupID'][0]
|
||||
(x) => x.id === (Array.isArray(listGroupId) ? listGroupId[0] : listGroupId)
|
||||
);
|
||||
|
||||
console.log('current selected topping list', row_uid, current_selected);
|
||||
logger.info('current selected topping list', row_uid, current_selected);
|
||||
|
||||
if (currentToppings[getToppingSlot()]['ListGroupID'][0] === 0) {
|
||||
if (!currentToppings[slot]['ListGroupID'] || currentToppings[slot]['ListGroupID'][0] === 0) {
|
||||
return 'Empty';
|
||||
}
|
||||
if (current_selected === undefined || current_selected === null) {
|
||||
|
|
@ -226,13 +258,20 @@
|
|||
|
||||
extractStringParam();
|
||||
|
||||
// Sync local $state copies from prop values on mount
|
||||
displayWater = { ...water };
|
||||
displayStirTime = stir_time;
|
||||
displayFeed = { ...feed };
|
||||
displayPowder = { ...powder };
|
||||
displaySyrup = { ...syrup };
|
||||
|
||||
if (isToppingId(mat_id) && onDetectToppingSlot) {
|
||||
// FIXME: this should not be updated yet, must be updated after user pressed save only
|
||||
let latest_topping_data_snapshot = $state.snapshot(get(latestRecipeToppingData));
|
||||
currentToppings = latest_topping_data_snapshot;
|
||||
// console.log('current topping in initialize', currentToppings);
|
||||
// logger.info('current topping in initialize', currentToppings);
|
||||
if (oldToppings == null) {
|
||||
// console.log('saving original topping', oldToppings);
|
||||
// logger.info('saving original topping', oldToppings);
|
||||
oldToppings = $state.snapshot(currentToppings);
|
||||
}
|
||||
|
||||
|
|
@ -248,15 +287,15 @@
|
|||
let mat_type_t1 = getMaterialType(mat_num);
|
||||
let mat_type_t2 = getMaterialType(convertFromInterProductCode(mat_num));
|
||||
|
||||
// console.log('type get', mat_type_t1, mat_type_t2);
|
||||
// logger.info('type get', mat_type_t1, mat_type_t2);
|
||||
currentMaterialType = mat_type_t1 === mat_type_t2 ? mat_type_t1 : mat_type_t2;
|
||||
|
||||
// if (hasMixOrder) {
|
||||
// console.log('detect mix order', mat_num);
|
||||
// logger.info('detect mix order', mat_num);
|
||||
// }
|
||||
|
||||
// if (feed.parameter > 0 || feed.pattern > 0) {
|
||||
// console.log('has feed fields', JSON.stringify(feed));
|
||||
// logger.info('has feed fields', JSON.stringify(feed));
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -267,127 +306,177 @@
|
|||
for (const key of keys) {
|
||||
if (key == 'toppings' && currentMaterialType == 'topping') {
|
||||
let topping_change = value[key][getToppingSlot()];
|
||||
console.log('topping applying', topping_change);
|
||||
logger.info('topping applying', topping_change);
|
||||
currentToppings[getToppingSlot()] = topping_change;
|
||||
} else {
|
||||
console.log('apply change on recipelist value', key);
|
||||
logger.info('apply change on recipelist value', key);
|
||||
|
||||
if (key.startsWith('powder')) {
|
||||
if (key.endsWith('gram')) {
|
||||
powder.gram = parseInt(value[key]);
|
||||
displayPowder.gram = parseInt(value[key]);
|
||||
} else if (key.endsWith('time')) {
|
||||
powder.time = parseInt(value[key]);
|
||||
displayPowder.time = parseInt(value[key]);
|
||||
}
|
||||
} else if (key.startsWith('syrup')) {
|
||||
if (key.endsWith('gram')) {
|
||||
syrup.gram = parseInt(value[key]);
|
||||
displaySyrup.gram = parseInt(value[key]);
|
||||
} else if (key.endsWith('time')) {
|
||||
syrup.time = parseInt(value[key]);
|
||||
displaySyrup.time = parseInt(value[key]);
|
||||
}
|
||||
} else if (key.startsWith('water')) {
|
||||
if (key.endsWith('yield')) {
|
||||
water.yield = parseInt(value[key]);
|
||||
displayWater.yield = parseInt(value[key]);
|
||||
} else if (key.endsWith('cold')) {
|
||||
water.cold = parseInt(value[key]);
|
||||
displayWater.cold = parseInt(value[key]);
|
||||
}
|
||||
} else if (key.startsWith('feed')) {
|
||||
if (key.endsWith('pattern')) {
|
||||
feed.pattern = parseInt(value[key]);
|
||||
displayFeed.pattern = parseInt(value[key]);
|
||||
} else if (key.endsWith('parameter')) {
|
||||
feed.parameter = parseInt(value[key]);
|
||||
displayFeed.parameter = parseInt(value[key]);
|
||||
}
|
||||
} else if (key == 'stir_time') {
|
||||
stir_time = parseInt(value[key]);
|
||||
displayStirTime = parseInt(value[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvents(event: { event_type: string; payload: any; index: number | undefined }) {
|
||||
// console.log('triggered event', event.event_type, JSON.stringify(event.payload));
|
||||
// logger.info('triggered event', event.event_type, JSON.stringify(event.payload));
|
||||
if (event.event_type === 'mat_change') {
|
||||
// update value, do re-render
|
||||
initialize();
|
||||
} else if (event.event_type === 'edit_mat_field') {
|
||||
console.log('request edit mat');
|
||||
logger.info('request edit mat');
|
||||
|
||||
// fix bug: topping instant change by unlink topping
|
||||
let _current_toppings = $state.snapshot(currentToppings);
|
||||
|
||||
// pack all shown data
|
||||
recipeDataEvent.set({
|
||||
event_type: 'edit_mat_field_prep',
|
||||
payload: {
|
||||
mat_id: currentMaterialInt,
|
||||
mat_type: currentMaterialType,
|
||||
mat_name: mat_id,
|
||||
params: currentStringParams,
|
||||
toppings: _current_toppings,
|
||||
current_topping_group: currentToppingInRow,
|
||||
current_topping_list: selectableToppingInGroup,
|
||||
has_mix_ord: hasMixOrder,
|
||||
feed,
|
||||
water,
|
||||
powder,
|
||||
syrup,
|
||||
stir_time
|
||||
},
|
||||
index: row_uid
|
||||
});
|
||||
emitEditMatFieldPrep({
|
||||
mat_id: currentMaterialInt,
|
||||
mat_type: currentMaterialType,
|
||||
mat_name: mat_id,
|
||||
params: currentStringParams,
|
||||
toppings: _current_toppings,
|
||||
current_topping_group: currentToppingInRow,
|
||||
current_topping_list: selectableToppingInGroup,
|
||||
has_mix_ord: hasMixOrder,
|
||||
feed,
|
||||
water,
|
||||
powder,
|
||||
syrup,
|
||||
stir_time
|
||||
}, row_uid);
|
||||
} else if (event.event_type === 'save_mat_field') {
|
||||
console.log('receive saving process mat, do refresh...');
|
||||
logger.info('receive saving process mat, do refresh...');
|
||||
let change_values = event.payload.change;
|
||||
|
||||
// apply now
|
||||
let keys = Object.keys(change_values);
|
||||
console.log('change keys', JSON.stringify(keys));
|
||||
logger.info('change keys', JSON.stringify(keys));
|
||||
let _current_toppings = $state.snapshot(currentToppings);
|
||||
|
||||
// initialize();
|
||||
applyChanges(change_values);
|
||||
|
||||
event.payload = {
|
||||
let enhanced_payload = {
|
||||
current_toppings: _current_toppings,
|
||||
...event.payload
|
||||
};
|
||||
|
||||
// console.log('check on change before trigger', change_values);
|
||||
// Store for synchronous read by recipe-editor-dialog
|
||||
setPendingSaveData({
|
||||
change: change_values,
|
||||
payload: enhanced_payload,
|
||||
index: row_uid
|
||||
});
|
||||
|
||||
if (onEditValue) onEditValue(JSON.stringify(event.payload));
|
||||
if (onEditValue) onEditValue(JSON.stringify(enhanced_payload));
|
||||
} else if (event.event_type === 'revert_change') {
|
||||
// console.log('revert back ...', row_uid);
|
||||
// logger.info('revert back ...', row_uid);
|
||||
|
||||
currentToppings = oldToppings;
|
||||
clearPendingSaveData();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initialize();
|
||||
unsubRecipeDataEvent = recipeDataEvent.subscribe((event) => {
|
||||
if (event !== null && event.index !== undefined && event.index === row_uid) {
|
||||
// has some event
|
||||
handleEvents(event);
|
||||
}
|
||||
});
|
||||
toppingDataLoading = false;
|
||||
|
||||
// Subscribe to GlobalEventBus for recipe editor events
|
||||
unsubRecipeDataEvent = (() => {
|
||||
const unsubMatChange = GlobalEventBus.on(RECIPE_EVENTS.MAT_CHANGE, (data: any) => {
|
||||
if (data && data.index === row_uid) {
|
||||
handleEvents({
|
||||
event_type: 'mat_change',
|
||||
payload: data.mat_id,
|
||||
index: data.index
|
||||
});
|
||||
}
|
||||
});
|
||||
const unsubEditMatField = GlobalEventBus.on(RECIPE_EVENTS.EDIT_MAT_FIELD, (data: any) => {
|
||||
if (data && data.index === row_uid) {
|
||||
handleEvents({
|
||||
event_type: 'edit_mat_field',
|
||||
payload: data.payload,
|
||||
index: data.index
|
||||
});
|
||||
}
|
||||
});
|
||||
const unsubSaveMatField = GlobalEventBus.on(RECIPE_EVENTS.SAVE_MAT_FIELD, (data: any) => {
|
||||
if (data && data.index === row_uid) {
|
||||
handleEvents({
|
||||
event_type: 'save_mat_field',
|
||||
payload: data.payload,
|
||||
index: data.index
|
||||
});
|
||||
}
|
||||
});
|
||||
const unsubRevertChange = GlobalEventBus.on(RECIPE_EVENTS.REVERT_CHANGE, (data: any) => {
|
||||
if (data && (data.index === -1 || data.index === row_uid)) {
|
||||
handleEvents({
|
||||
event_type: 'revert_change',
|
||||
payload: data.payload,
|
||||
index: data.index
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubMatChange();
|
||||
unsubEditMatField();
|
||||
unsubSaveMatField();
|
||||
unsubRevertChange();
|
||||
};
|
||||
})();
|
||||
|
||||
// Reactively update topping display when external store changes.
|
||||
// This replaces the old $effect that caused an infinite loop
|
||||
// (the effect wrote $state variables that re-triggered the effect).
|
||||
unsubRecipeTopping = latestRecipeToppingData.subscribe((payload) => {
|
||||
// console.log('topping data subscribe', payload);
|
||||
if (payload && currentMaterialType === 'topping') {
|
||||
currentToppings = payload;
|
||||
getToppingDisplay();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubRecipeDataEvent) {
|
||||
// do last event before destroy
|
||||
// console.log('trigger last event before destroy', get(latestRecipeToppingData));
|
||||
handleEvents(get(recipeDataEvent) ?? { event_type: '', payload: {}, index: -1 });
|
||||
|
||||
unsubRecipeDataEvent();
|
||||
// console.log('destroy recipe data event listener');
|
||||
}
|
||||
if (unsubRecipeTopping) {
|
||||
unsubRecipeTopping();
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: Topping display reactivity is now handled by the store subscription
|
||||
// in onMount (latestRecipeToppingData.subscribe). The old $effect approach
|
||||
// caused an infinite loop because getToppingDisplay() writes $state variables
|
||||
// that re-triggered the effect. Using the store subscription breaks the cycle
|
||||
// because store callbacks don't participate in Svelte 5 signal tracking.
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
@ -395,21 +484,27 @@
|
|||
<!-- do topping layout -->
|
||||
<div>
|
||||
<!-- get name of topping -->
|
||||
<div class="mx-auto my-4 flex flex-row gap-8">
|
||||
<p class="text-muted-foreground">
|
||||
<b>
|
||||
{getCurrentSelectedToppingGroup()}
|
||||
</b>, {getCurrentSelectedToppingList()}
|
||||
</p>
|
||||
{#if oldToppingNamesOnly != null}
|
||||
<br />
|
||||
<Button variant="outline" class="font-bold text-red-500">
|
||||
<!-- old topping -->
|
||||
<Undo />
|
||||
<!-- {oldToppingNamesOnly} -->
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if toppingDataLoading}
|
||||
<div class="mx-auto my-4">
|
||||
<p class="text-muted-foreground italic">Loading...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto my-4 flex flex-row gap-8">
|
||||
<p class="text-muted-foreground">
|
||||
<b>
|
||||
{getCurrentSelectedToppingGroup()}
|
||||
</b>, {getCurrentSelectedToppingList()}
|
||||
</p>
|
||||
{#if oldToppingNamesOnly != null}
|
||||
<br />
|
||||
<Button variant="outline" class="font-bold text-red-500">
|
||||
<!-- old topping -->
|
||||
<Undo />
|
||||
<!-- {oldToppingNamesOnly} -->
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
|
|
@ -440,7 +535,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if water.yield > 0}
|
||||
{#if displayWater.yield > 0}
|
||||
<div class="my-4">
|
||||
<!-- <Label class="font-bold" for={`water_yield_volume_${row_uid}`}>Hot</Label>
|
||||
<div class="flex flex-row items-center space-x-2 text-center">
|
||||
|
|
@ -448,7 +543,7 @@
|
|||
class="w-16"
|
||||
type="number"
|
||||
id={`water_yield_volume_${row_uid}`}
|
||||
value={water.yield}
|
||||
value={displayWater.yield}
|
||||
onchangecapture={(v) =>
|
||||
triggerEditChange(`${v.currentTarget.id}=${v.currentTarget.value}`)}
|
||||
/>
|
||||
|
|
@ -457,12 +552,12 @@
|
|||
|
||||
<p class="text-muted-foreground">
|
||||
<b> Hot </b>
|
||||
{water.yield} ml
|
||||
{displayWater.yield} ml
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if water.cold > 0}
|
||||
{#if displayWater.cold > 0}
|
||||
<div class="my-4">
|
||||
<!-- <Label class="font-bold" for={`water_cold_volume_${row_uid}`}>Cold</Label>
|
||||
<div class="flex flex-row items-center space-x-2 text-center">
|
||||
|
|
@ -470,7 +565,7 @@
|
|||
class="w-16"
|
||||
type="number"
|
||||
id={`water_cold_volume_${row_uid}`}
|
||||
value={water.cold}
|
||||
value={displayWater.cold}
|
||||
onchangecapture={(v) =>
|
||||
triggerEditChange(`${v.currentTarget.id}=${v.currentTarget.value}`)}
|
||||
/>
|
||||
|
|
@ -478,12 +573,12 @@
|
|||
</div> -->
|
||||
<p class="text-muted-foreground">
|
||||
<b> Hot </b>
|
||||
{water.cold} ml
|
||||
{displayWater.cold} ml
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if currentMaterialType !== 'cup' && currentMaterialType !== 'topping' && stir_time > 0}
|
||||
{#if currentMaterialType !== 'cup' && currentMaterialType !== 'topping' && displayStirTime > 0}
|
||||
<div class="my-4">
|
||||
<!-- <Label class="font-bold" for={`stir_time_${row_uid}`}>{getStirTimeName()}</Label>
|
||||
<div class="flex flex-row items-center space-x-2 text-center">
|
||||
|
|
@ -491,7 +586,7 @@
|
|||
class="w-16"
|
||||
type="number"
|
||||
id={`stir_time_${row_uid}`}
|
||||
value={stir_time / 10}
|
||||
value={displayStirTime / 10}
|
||||
onchangecapture={(v) =>
|
||||
triggerEditChange(`${v.currentTarget.id}=${v.currentTarget.value}`)}
|
||||
/>
|
||||
|
|
@ -499,7 +594,7 @@
|
|||
</div> -->
|
||||
<p class="text-muted-foreground">
|
||||
<b>{getStirTimeName()}</b>
|
||||
{stir_time / 10} sec(s)
|
||||
{displayStirTime / 10} sec(s)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -527,10 +622,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
<!-- feed pattern -->
|
||||
{#if feed.parameter > 0 || feed.pattern > 0}
|
||||
{#if displayFeed.parameter > 0 || displayFeed.pattern > 0}
|
||||
<div class="mx-auto my-4 flex items-center gap-8 text-center">
|
||||
<p class="text-muted-foreground">Style {feed.pattern}</p>
|
||||
<p class="text-muted-foreground">Level {feed.parameter}</p>
|
||||
<p class="text-muted-foreground">Style {displayFeed.pattern}</p>
|
||||
<p class="text-muted-foreground">Level {displayFeed.parameter}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import {
|
||||
currentEditingRecipeProductCode,
|
||||
latestRecipeToppingData,
|
||||
recipeFromMachine,
|
||||
recipeFromMachineQuery,
|
||||
recipeFromServerQuery,
|
||||
referenceFromPage
|
||||
|
|
@ -21,7 +24,8 @@
|
|||
import { sendToAndroid } from '$lib/core/stores/adbWriter';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
import { recipeDataEvent } from '$lib/core/stores/recipeStore';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { RECIPE_EVENTS, getPendingSaveData } from './recipe-details/recipe-editor-events';
|
||||
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { departmentStore } from '$lib/core/stores/departments';
|
||||
|
|
@ -57,7 +61,7 @@
|
|||
let interval_get_machine_status: any;
|
||||
|
||||
async function onPendingChange(newChange: { target: string; value: any; ref_pd: string }) {
|
||||
console.log('detect pending change', matchMenuStatus(currentData.MenuStatus));
|
||||
logger.info('detect pending change', matchMenuStatus(currentData.MenuStatus));
|
||||
hasPendingChange = true;
|
||||
|
||||
let originalMenuStatus = matchMenuStatus(currentData.MenuStatus);
|
||||
|
|
@ -74,22 +78,18 @@
|
|||
currentData.MenuStatus = MenuStatus.pendingOnline;
|
||||
|
||||
if (newChange.target === 'recipeList') {
|
||||
// currentData.recipes = newChange.value;
|
||||
//
|
||||
// TODO: build into structure, flatten fields into 1 layer, strip off `id` (row id)
|
||||
console.log('pending change recipe list', newChange);
|
||||
logger.info('pending change recipe list', newChange);
|
||||
|
||||
let isMatchedProduct = newChange.ref_pd == productCode;
|
||||
|
||||
// get latest edit
|
||||
let latest_event = get(recipeDataEvent);
|
||||
// expect edit_change_value_rpl
|
||||
console.log('latest data event', latest_event);
|
||||
// get latest edit from pending save data
|
||||
let latest_save = getPendingSaveData();
|
||||
logger.info('latest pending save data', latest_save);
|
||||
|
||||
let topping_value_for_revert = latest_event?.payload.current_toppings;
|
||||
let new_topping_value_for_save = latest_event?.payload.source.toppings;
|
||||
let topping_value_for_revert = latest_save?.payload.current_toppings;
|
||||
let new_topping_value_for_save = latest_save?.payload.source.toppings;
|
||||
|
||||
// console.log(
|
||||
// logger.info(
|
||||
// 'topping_data_latest',
|
||||
// get(latestRecipeToppingData),
|
||||
// 'topping_old',
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
// get(currentEditingRecipeProductCode)
|
||||
// );
|
||||
|
||||
console.log('accepting changes');
|
||||
logger.info('accepting changes');
|
||||
let current_data_to_brew =
|
||||
ready_to_send_brew.length == 0 ? $state.snapshot(currentData) : ready_to_send_brew.shift();
|
||||
|
||||
|
|
@ -121,27 +121,67 @@
|
|||
// current_data_to_brew['ToppingSet'] = topping_value_for_revert;
|
||||
// }
|
||||
|
||||
// Reorder current_data_to_brew.recipes to match the drag-and-drop order.
|
||||
// newChange.value[].id = original index in the recipes array.
|
||||
// NOTE: We ONLY reorder the queue snapshot (current_data_to_brew), NOT the
|
||||
// $state (currentData.recipes). Updating currentData.recipes would cause
|
||||
// recipeListMatState to re-derive → table gets new data prop → TanStack
|
||||
// fires onStateChange → checkChanges fires onPendingChange → infinite loop.
|
||||
// The table already manages its own local data for correct visual display.
|
||||
if (
|
||||
newChange.value &&
|
||||
Array.isArray(newChange.value) &&
|
||||
current_data_to_brew.recipes &&
|
||||
newChange.value.length === current_data_to_brew.recipes.length
|
||||
) {
|
||||
const oldRecipes = [...current_data_to_brew.recipes];
|
||||
// Use currentData.recipes for the ID→recipe lookup — it is NEVER
|
||||
// reordered (updating currentData.recipes would cause an infinite loop
|
||||
// per the rule at lines 126-130, so it stays in original index order).
|
||||
// oldRecipes reflects the queue snapshot which MAY have been reordered
|
||||
// by a previous drag, making oldRecipes[item.id] incorrect because
|
||||
// item.id is the ORIGINAL array index, not the current position.
|
||||
const originalRecipes: any[] = currentData?.recipes ?? oldRecipes;
|
||||
const reordered = newChange.value.map(
|
||||
(item: any) => originalRecipes[item.id] ?? oldRecipes[item.id]
|
||||
);
|
||||
current_data_to_brew.recipes = reordered;
|
||||
|
||||
logger.info('reordered recipes by drag-and-drop', reordered, oldRecipes, {
|
||||
originalRecipesLength: originalRecipes.length,
|
||||
usingOriginalRecipes: originalRecipes !== oldRecipes
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'reorder skipped — length mismatch or missing recipes',
|
||||
newChange.value?.length,
|
||||
current_data_to_brew.recipes?.length
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure ToppingSet has no null entries before applying further changes.
|
||||
sanitizeToppingSet(current_data_to_brew);
|
||||
|
||||
// apply changes now
|
||||
let new_change = applyChangeToRecipeForBrewing(latest_event, current_data_to_brew);
|
||||
let new_change = applyChangeToRecipeForBrewing(latest_save, current_data_to_brew);
|
||||
|
||||
ready_to_send_brew.push(new_change);
|
||||
|
||||
callback_revert_value_if_not_save = async (save: any) => {
|
||||
if (!save) {
|
||||
latestRecipeToppingData.set(topping_value_for_revert);
|
||||
console.log('revert change', get(latestRecipeToppingData));
|
||||
recipeDataEvent.set({
|
||||
event_type: 'revert_change',
|
||||
logger.info('revert change', get(latestRecipeToppingData));
|
||||
GlobalEventBus.emit(RECIPE_EVENTS.REVERT_CHANGE, {
|
||||
payload: {},
|
||||
index: -1
|
||||
});
|
||||
} else {
|
||||
// topping part
|
||||
// latestRecipeToppingData.set(new_topping_value_for_save);
|
||||
// console.log('save change', get(latestRecipeToppingData));
|
||||
// logger.info('save change', get(latestRecipeToppingData));
|
||||
// currentData['ToppingSet'] = latestRecipeToppingData;
|
||||
|
||||
// console.log('current data', currentData);
|
||||
// logger.info('current data', currentData);
|
||||
let curr_user = get(auth);
|
||||
|
||||
let user_info: any;
|
||||
|
|
@ -168,6 +208,21 @@
|
|||
recipeFromMachineQuery.set(recipeDevSnapshot);
|
||||
}
|
||||
|
||||
// Also update recipeFromMachine (source-of-truth for brew page)
|
||||
// so that if buildOverviewForBrewing() is called later (e.g. via
|
||||
// handleMenuSaved), it rebuilds from the latest data.
|
||||
let machineRecipeSnapshot = get(recipeFromMachine);
|
||||
if (machineRecipeSnapshot?.Recipe01) {
|
||||
const targetCode = ready_to_send_brew[0]['productCode'];
|
||||
const idx = machineRecipeSnapshot.Recipe01.findIndex(
|
||||
(r: any) => r['productCode'] === targetCode
|
||||
);
|
||||
if (idx >= 0) {
|
||||
machineRecipeSnapshot.Recipe01[idx] = ready_to_send_brew[0];
|
||||
}
|
||||
recipeFromMachine.set(machineRecipeSnapshot);
|
||||
}
|
||||
|
||||
sendToAndroid({
|
||||
type: 'save_recipe_machine',
|
||||
payload: {
|
||||
|
|
@ -199,7 +254,7 @@
|
|||
payload: {
|
||||
user_info,
|
||||
country: get(departmentStore) ?? 'unknown',
|
||||
values: currentData
|
||||
values: ready_to_send_brew[0]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -240,32 +295,66 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Create a default empty topping-set entry (no topping configured). */
|
||||
function getDefaultToppingSet() {
|
||||
return { ListGroupID: [0, 0, 0, 0], defaultIDSelect: 0, groupID: '0', isUse: false };
|
||||
}
|
||||
|
||||
/** Guard: fill any null/undefined ToppingSet slots with defaults. */
|
||||
function sanitizeToppingSet(data: any) {
|
||||
if (data && data['ToppingSet']) {
|
||||
for (let i = 0; i < data['ToppingSet'].length; i++) {
|
||||
if (data['ToppingSet'][i] == null || data['ToppingSet'][i] == undefined) {
|
||||
data['ToppingSet'][i] = getDefaultToppingSet();
|
||||
logger.info('filled null ToppingSet slot', i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyChangeToRecipeForBrewing(latest_event: any, current_data_to_brew: any) {
|
||||
console.log('applyChangeToRecipeForBrewing', Object.keys(latest_event?.payload));
|
||||
logger.info('checkBeforeApplyChange', latest_event);
|
||||
|
||||
if (latest_event == null || latest_event == undefined) {
|
||||
sanitizeToppingSet(current_data_to_brew);
|
||||
return current_data_to_brew;
|
||||
}
|
||||
|
||||
logger.info('applyChangeToRecipeForBrewing', Object.keys(latest_event?.payload));
|
||||
|
||||
let changes = latest_event?.payload.change;
|
||||
|
||||
let apply_to_index = latest_event?.index;
|
||||
let apply_to_keys = Object.keys(changes);
|
||||
|
||||
console.log('applying', apply_to_index, apply_to_keys, current_data_to_brew['recipes'].length);
|
||||
logger.info('applying', apply_to_index, apply_to_keys, current_data_to_brew['recipes'].length);
|
||||
|
||||
console.log('topping before apply', current_data_to_brew['ToppingSet']);
|
||||
logger.info('topping before apply', current_data_to_brew['ToppingSet']);
|
||||
for (const key of apply_to_keys) {
|
||||
if (key == 'toppings') {
|
||||
current_data_to_brew['ToppingSet'][apply_to_index - 1] = changes[key][apply_to_index - 1];
|
||||
console.log('applying topping', apply_to_index - 1);
|
||||
// Find the actual topping-slot index from the sparse changes array.
|
||||
// The editor builds changes['toppings'] = new Array(N) with only the
|
||||
// slot being edited filled; all other entries are undefined. We must
|
||||
// NOT use apply_to_index (recipe row index) because ToppingSet is
|
||||
// indexed by material-path slot (0..19), NOT by recipe position.
|
||||
const toppingSlot = changes[key].findIndex((v: any) => v != null && v != undefined);
|
||||
if (toppingSlot >= 0) {
|
||||
current_data_to_brew['ToppingSet'][toppingSlot] = changes[key][toppingSlot];
|
||||
logger.info('applying topping at slot', toppingSlot);
|
||||
}
|
||||
} else {
|
||||
// get actual key
|
||||
|
||||
current_data_to_brew['recipes'][apply_to_index][getRecipeListKeyName(key)] = parseInt(
|
||||
changes[key]
|
||||
);
|
||||
console.log('applying ', key, current_data_to_brew['recipes'][apply_to_index]);
|
||||
logger.info('applying ', key, current_data_to_brew['recipes'][apply_to_index]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('topping after apply', current_data_to_brew['ToppingSet']);
|
||||
sanitizeToppingSet(current_data_to_brew);
|
||||
|
||||
logger.info('topping after apply', current_data_to_brew['ToppingSet']);
|
||||
|
||||
return current_data_to_brew;
|
||||
}
|
||||
|
|
@ -276,7 +365,7 @@
|
|||
ready_to_send_brew.shift();
|
||||
}
|
||||
|
||||
console.log('sending brewing payload', ready_to_send_brew);
|
||||
logger.info('sending brewing payload', ready_to_send_brew);
|
||||
|
||||
// save topping
|
||||
|
||||
|
|
@ -340,7 +429,7 @@
|
|||
let recipe01Snap = recipeServerSnapshot['recipe'];
|
||||
if (recipe01Snap) {
|
||||
currentData = recipe01Snap[productCode] ?? {};
|
||||
// console.log(`current data : ${JSON.stringify(Object.keys(recipe01Snap))}`);
|
||||
// logger.info(`current data : ${JSON.stringify(Object.keys(recipe01Snap))}`);
|
||||
if (currentData.MenuStatus) {
|
||||
currentMenuStatus = matchMenuStatus(currentData.MenuStatus);
|
||||
}
|
||||
|
|
@ -352,6 +441,12 @@
|
|||
// interval check 1s
|
||||
// machine
|
||||
if (refPage === 'brew') {
|
||||
// Clear any previous interval before creating a new one
|
||||
// ($effect can re-run when its dependencies change).
|
||||
if (interval_get_machine_status) {
|
||||
clearInterval(interval_get_machine_status);
|
||||
}
|
||||
|
||||
interval_get_machine_status = setInterval(() => {
|
||||
if (
|
||||
getMachineStatus() == undefined ||
|
||||
|
|
@ -362,7 +457,7 @@
|
|||
updateMachineStatus('');
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// logger.info(
|
||||
// 'machine status pinging recipe editor dialog',
|
||||
// getMachineStatus(),
|
||||
// $machineInfoStore?.status
|
||||
|
|
@ -377,11 +472,17 @@
|
|||
}
|
||||
});
|
||||
}, 1000);
|
||||
} else if (interval_get_machine_status) {
|
||||
// No longer on 'brew' page — clean up the interval
|
||||
clearInterval(interval_get_machine_status);
|
||||
interval_get_machine_status = null;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval_get_machine_status);
|
||||
if (interval_get_machine_status) {
|
||||
clearInterval(interval_get_machine_status);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -419,7 +520,7 @@
|
|||
save_change = true;
|
||||
|
||||
callback_revert_value_if_not_save(save_change);
|
||||
await saveRecipeMenuFileToAndroid();
|
||||
// await saveRecipeMenuFileToAndroid();
|
||||
|
||||
addNotification('INFO:Save recipe');
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
closeTerminalDrawer
|
||||
} from '$lib/core/stores/terminalDrawer';
|
||||
import { getAdbInstance } from '$lib/core/adb/adb';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import {
|
||||
initTerminalSession,
|
||||
reinitTerminalSession,
|
||||
|
|
@ -270,17 +271,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Check connection status periodically
|
||||
let _connInterval: ReturnType<typeof setInterval> | undefined;
|
||||
// Subscribe to the shared connection store instead of polling every 2s.
|
||||
// The store is updated by adb.ts whenever a device connects or disconnects.
|
||||
let _unsubConnStatus: (() => void) | undefined;
|
||||
onMount(() => {
|
||||
_connInterval = setInterval(() => {
|
||||
const instance = getAdbInstance();
|
||||
connStatus = instance ? 'connected' : 'disconnected';
|
||||
}, 2000);
|
||||
_unsubConnStatus = adbConnectionStatus.subscribe((status) => {
|
||||
connStatus = status === 'connected' ? 'connected' : 'disconnected';
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (_connInterval) clearInterval(_connInterval);
|
||||
_unsubConnStatus?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
|
||||
import { Xterm, XtermAddon } from '@battlefieldduck/xterm-svelte';
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
};
|
||||
|
||||
async function onLoad() {
|
||||
console.log('[Term] loaded');
|
||||
logger.info('[Term] loaded');
|
||||
|
||||
const xmod = await XtermAddon.FitAddon();
|
||||
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
}
|
||||
|
||||
async function onKey(data: { key: string; domEvent: KeyboardEvent }) {
|
||||
console.log('on key', data, data.domEvent.key == 'Backspace');
|
||||
logger.info('on key', data, data.domEvent.key == 'Backspace');
|
||||
|
||||
// TODO: implement arror keys
|
||||
let isArrowKeys = data.domEvent.key.startsWith('Arrow');
|
||||
|
|
@ -67,13 +68,13 @@
|
|||
// let txt = document.getElementById("cmd-input") as HTMLInputElement;
|
||||
|
||||
let input_cmd = get(chars);
|
||||
console.log('instance existed, ', input_cmd);
|
||||
logger.info('instance existed, ', input_cmd);
|
||||
|
||||
terminal?.writeln('');
|
||||
|
||||
if ((await handleNonAdbCmd(input_cmd ?? '')) < 0) {
|
||||
let result = await adb.executeCmd(input_cmd ?? '');
|
||||
console.log(result);
|
||||
logger.info(result);
|
||||
if (result.output) {
|
||||
terminal?.writeln(result.output);
|
||||
} else if (result.error) {
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
current_data != null &&
|
||||
current_data.length > 0
|
||||
) {
|
||||
console.log('back ', current_data);
|
||||
logger.info('back ', current_data);
|
||||
current_data = current_data!.slice(0, -1);
|
||||
chars.set(current_data);
|
||||
terminal?.write('\b \b');
|
||||
|
|
@ -113,24 +114,19 @@
|
|||
// -1: unknown command or failed
|
||||
// 0: ok
|
||||
async function handleNonAdbCmd(cmd: string) {
|
||||
// SECURITY: eval() removed — arbitrary code execution risk.
|
||||
// Use explicit commands only (clear, help, etc.).
|
||||
if (cmd.trim().startsWith('eval')) {
|
||||
let eval_statement = cmd.replace('eval', '').trim();
|
||||
console.log('get program: ', eval_statement);
|
||||
try {
|
||||
let result = eval(eval_statement);
|
||||
console.log('result: ', result);
|
||||
terminal?.writeln(`${result}`);
|
||||
} catch (e) {
|
||||
terminal?.writeln(`${e}`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
terminal?.writeln('eval is disabled for security. Use explicit commands only.');
|
||||
return 0;
|
||||
} else {
|
||||
switch (cmd) {
|
||||
switch (cmd.trim()) {
|
||||
case 'clear':
|
||||
terminal?.clear();
|
||||
return 0;
|
||||
case 'help':
|
||||
terminal?.writeln('Available commands: clear, help');
|
||||
return 0;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { Adb, AdbDaemonTransport, encodeUtf8 } from '@yume-chan/adb';
|
||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
||||
import {
|
||||
|
|
@ -16,8 +17,60 @@ import { adbWriter } from '../stores/adbWriter';
|
|||
import { WritableStream } from '@yume-chan/stream-extra';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { get } from 'svelte/store';
|
||||
import { adbConnectionStatus } from '../stores/adbConnectionStore';
|
||||
|
||||
let _connectionPromise: Promise<boolean> | null = null;
|
||||
let syncConnection: any = null;
|
||||
|
||||
/**
|
||||
* Centralized connection function that all pages and components should use.
|
||||
*
|
||||
* - If a device is already connected, returns `true` immediately.
|
||||
* - If a connection is already in progress, waits for it and returns its result.
|
||||
* - Otherwise attempts to connect using stored credentials first
|
||||
* (AdbDaemonWebUsbDeviceManager.BROWSER.getDevices), falling back to
|
||||
* prompting the user with the browser's WebUSB device picker.
|
||||
*
|
||||
* This deduplicates connection attempts across the app: no matter how many
|
||||
* pages/components call it simultaneously, only one WebUSB handshake happens.
|
||||
*
|
||||
* @returns `true` when a device is (or became) connected, `false` otherwise.
|
||||
*/
|
||||
export async function ensureAdbConnection(): Promise<boolean> {
|
||||
// Already connected.
|
||||
if (getAdbInstance()) return true;
|
||||
|
||||
// Already in progress — wait for it.
|
||||
if (_connectionPromise) return await _connectionPromise;
|
||||
|
||||
adbConnectionStatus.setConnecting();
|
||||
|
||||
_connectionPromise = (async () => {
|
||||
try {
|
||||
const deviceManager = AdbDaemonWebUsbDeviceManager.BROWSER;
|
||||
if (deviceManager && 'usb' in navigator) {
|
||||
const devices = await deviceManager.getDevices();
|
||||
if (devices && devices.length === 1) {
|
||||
const credStore = new AdbWebCredentialStore();
|
||||
await connectDeviceByCred(devices[0], credStore);
|
||||
return Boolean(getAdbInstance());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prompt user to pick a device.
|
||||
await connnectViaWebUSB();
|
||||
return Boolean(getAdbInstance());
|
||||
} catch (e) {
|
||||
logger.error('ensureAdbConnection failed', e);
|
||||
adbConnectionStatus.setDisconnected();
|
||||
return false;
|
||||
} finally {
|
||||
_connectionPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return await _connectionPromise;
|
||||
}
|
||||
let syncOperation: Promise<unknown> = Promise.resolve();
|
||||
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
|
||||
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
|
||||
|
|
@ -137,10 +190,11 @@ async function connectWithRetry<T>(
|
|||
}
|
||||
|
||||
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||
console.log('usb ok', globalThis.navigator.usb);
|
||||
logger.info('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
|
||||
if (device) {
|
||||
console.log('connect ', device.name);
|
||||
logger.info('connect ', device.name);
|
||||
|
||||
try {
|
||||
const credentialStore = new AdbWebCredentialStore();
|
||||
|
|
@ -161,7 +215,7 @@ export async function connnectViaWebUSB(connectAndroidServer = true) {
|
|||
// save device info
|
||||
await deviceCredentialManager.saveDeviceInfo(device);
|
||||
} catch (e: any) {
|
||||
console.error('error on connect', e);
|
||||
logger.error('error on connect', e);
|
||||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
|
|
@ -179,6 +233,7 @@ export async function connectDeviceByCred(
|
|||
credStore: AdbWebCredentialStore,
|
||||
connectAndroidServer = true
|
||||
) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
try {
|
||||
const connection = await device.connect();
|
||||
const transport = await AdbDaemonTransport.authenticate({
|
||||
|
|
@ -196,6 +251,7 @@ export async function connectDeviceByCred(
|
|||
|
||||
return true;
|
||||
} catch (error) {
|
||||
adbConnectionStatus.setDisconnected();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -203,6 +259,11 @@ export async function connectDeviceByCred(
|
|||
export async function saveAdbInstance(adb: Adb | undefined) {
|
||||
await cleanupSync();
|
||||
AdbInstance.instance = adb;
|
||||
if (adb) {
|
||||
adbConnectionStatus.setConnected();
|
||||
} else {
|
||||
adbConnectionStatus.setDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdbInstance() {
|
||||
|
|
@ -210,6 +271,7 @@ export function getAdbInstance() {
|
|||
}
|
||||
|
||||
export async function connectRecipeMenuViaWebUSB() {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const currentInstance = getAdbInstance();
|
||||
if (currentInstance) {
|
||||
await connectToAndroidRecipeMenuServer();
|
||||
|
|
@ -218,9 +280,9 @@ export async function connectRecipeMenuViaWebUSB() {
|
|||
if (recipeMenuAdbConnectPromise) return await recipeMenuAdbConnectPromise;
|
||||
|
||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||
console.log('recipe menu usb ok', 'usb' in globalThis.navigator);
|
||||
logger.info('recipe menu usb ok', 'usb' in globalThis.navigator);
|
||||
if (device) {
|
||||
console.log('recipe menu connect ', device.name);
|
||||
logger.info('recipe menu connect ', device.name);
|
||||
|
||||
try {
|
||||
const credentialStore = new AdbWebCredentialStore();
|
||||
|
|
@ -230,7 +292,7 @@ export async function connectRecipeMenuViaWebUSB() {
|
|||
await deviceCredentialManager.saveDeviceInfo(device);
|
||||
return adb;
|
||||
} catch (e: any) {
|
||||
console.error('recipe menu connect error', e);
|
||||
logger.error('recipe menu connect error', e);
|
||||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
|
|
@ -249,6 +311,7 @@ export async function connectRecipeMenuDeviceByCred(
|
|||
device: AdbDaemonWebUsbDevice,
|
||||
credStore: AdbWebCredentialStore
|
||||
) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const currentInstance = getAdbInstance();
|
||||
if (currentInstance) {
|
||||
await connectToAndroidRecipeMenuServer();
|
||||
|
|
@ -306,20 +369,35 @@ export async function sendRecipeMenuMessageToAndroid(message: any) {
|
|||
try {
|
||||
const encoder = new TextEncoder();
|
||||
await writer.write(encoder.encode(JSON.stringify(message) + '\n'));
|
||||
console.log('recipe menu sent! ', JSON.stringify(message).length);
|
||||
logger.info('recipe menu sent! ', JSON.stringify(message).length);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('recipe menu write failed', error);
|
||||
logger.error('recipe menu write failed', error);
|
||||
addNotification(`ERR:Failed to send recipe menu\n${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject shell metacharacters that enable command injection.
|
||||
* Allows alphanumerics, spaces, common paths (/ - _ .), and ADB shell primitives.
|
||||
*/
|
||||
const UNSAFE_SHELL_PATTERN = /[;&|`$(){}[\]<>!#~*?\\\n\r]/;
|
||||
|
||||
function sanitizeAdbCommand(command: string): string {
|
||||
if (UNSAFE_SHELL_PATTERN.test(command)) {
|
||||
logger.warn(`Command contains unsafe characters, rejecting: ${command.slice(0, 80)}`);
|
||||
throw new Error(`Command rejected: contains shell metacharacters`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
export async function executeCmd(command: string) {
|
||||
command = sanitizeAdbCommand(command);
|
||||
let instance = getAdbInstance();
|
||||
|
||||
if (!instance) {
|
||||
console.error('instance not found');
|
||||
logger.error('instance not found');
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
@ -348,7 +426,7 @@ export async function executeCmd(command: string) {
|
|||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
// console.log(e.message);
|
||||
// logger.info(e.message);
|
||||
//ExactReadable ended
|
||||
if (e.message.includes('ExactReadable ended')) {
|
||||
return {
|
||||
|
|
@ -358,11 +436,20 @@ export async function executeCmd(command: string) {
|
|||
};
|
||||
}
|
||||
|
||||
console.error('error while execute command', e);
|
||||
logger.error('error while execute command', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -377,6 +464,7 @@ export async function executeStreamingCmd(
|
|||
onExit?: (exitCode: number | undefined) => void;
|
||||
}
|
||||
): Promise<() => void> {
|
||||
command = sanitizeAdbCommand(command);
|
||||
const instance = getAdbInstance();
|
||||
let aborted = false;
|
||||
|
||||
|
|
@ -445,7 +533,7 @@ export async function disconnect() {
|
|||
if (instance) {
|
||||
try {
|
||||
await instance.close();
|
||||
console.log('close instance');
|
||||
logger.info('close instance');
|
||||
} finally {
|
||||
await saveAdbInstance(undefined);
|
||||
}
|
||||
|
|
@ -457,7 +545,7 @@ export async function cleanupSync() {
|
|||
try {
|
||||
await syncConnection.dispose();
|
||||
} catch (e) {
|
||||
console.error('error on dispose sync', e);
|
||||
logger.error('error on dispose sync', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +577,7 @@ export async function pull(filename: string, timeoutMs: number = 5000) {
|
|||
return result_string;
|
||||
}
|
||||
} catch (pull_error: any) {
|
||||
console.log('pulling error', pull_error);
|
||||
logger.info('pulling error', pull_error);
|
||||
} finally {
|
||||
await cleanupSync();
|
||||
}
|
||||
|
|
@ -511,14 +599,14 @@ export async function push(path: string, obj: string) {
|
|||
});
|
||||
|
||||
try {
|
||||
console.log('support push v2', sync.supportsSendReceiveV2);
|
||||
logger.info('support push v2', sync.supportsSendReceiveV2);
|
||||
|
||||
await sync.write({
|
||||
filename: path,
|
||||
file
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error while trying to write to machine', error);
|
||||
logger.info('error while trying to write to machine', error);
|
||||
} finally {
|
||||
await sync.dispose();
|
||||
}
|
||||
|
|
@ -563,7 +651,7 @@ export async function pushBinary(
|
|||
onProgress?.(total, total);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('error while pushing binary to machine', error);
|
||||
logger.info('error while pushing binary to machine', error);
|
||||
return false;
|
||||
} finally {
|
||||
await sync.dispose();
|
||||
|
|
@ -582,7 +670,7 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
try {
|
||||
let inst = getAdbInstance();
|
||||
if (!inst) {
|
||||
console.warn('adb instance not found');
|
||||
logger.warn('adb instance not found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -599,7 +687,7 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
console.log('checking on writer ', writer);
|
||||
// logger.info('checking on writer ', writer);
|
||||
adbWriter.set(writer);
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Brewing Mode T on machine');
|
||||
|
|
@ -632,18 +720,19 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
// handleAdbPayload(new TextDecoder().decode(value));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('read error', e);
|
||||
logger.error('read error', e);
|
||||
if (isRecoverableError(e)) {
|
||||
void connectToAndroidServer();
|
||||
}
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Brewing Mode T Offline ...');
|
||||
}
|
||||
})();
|
||||
return;
|
||||
} else {
|
||||
addNotification('WARN:Brewing Mode T unavailable');
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Brewing Mode T Offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
})();
|
||||
return;
|
||||
} else {
|
||||
addNotification('WARN:Brewing Mode T unavailable');
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
|
||||
|
|
@ -671,7 +760,7 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
}
|
||||
|
||||
if (lastError) {
|
||||
console.error('Connection failed. Suspect java running or not', lastError);
|
||||
logger.error('Connection failed. Suspect java running or not', lastError);
|
||||
addNotification(`ERR:Fail to enable brewing mode T\n${lastError.message ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -693,7 +782,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
try {
|
||||
let inst = getAdbInstance();
|
||||
if (!inst) {
|
||||
console.warn('recipe menu adb instance not found');
|
||||
logger.warn('recipe menu adb instance not found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -704,7 +793,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
console.log('checking recipe menu writer ', writer);
|
||||
logger.info('checking recipe menu writer ', writer);
|
||||
adbWriter.set(writer);
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Android recipe menu channel');
|
||||
|
|
@ -712,7 +801,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
addNotification('WARN:Android recipe menu channel unavailable');
|
||||
|
||||
setTimeout(async () => {
|
||||
console.log('reconnecting android recipe menu server');
|
||||
logger.info('reconnecting android recipe menu server');
|
||||
await connectToAndroidRecipeMenuServer();
|
||||
}, 5000);
|
||||
}
|
||||
|
|
@ -726,7 +815,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
if (done) break;
|
||||
|
||||
const decoded = decoder.decode(value, { stream: true });
|
||||
console.log('[ADB Reader] Received raw:', decoded.slice(0, 200));
|
||||
logger.info('[ADB Reader] Received raw:', decoded.slice(0, 200));
|
||||
messageBuffer += decoded;
|
||||
const messages = messageBuffer.split('\n');
|
||||
messageBuffer = messages.pop() ?? '';
|
||||
|
|
@ -734,7 +823,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
for (const message of messages) {
|
||||
const trimmedMessage = message.trim();
|
||||
if (trimmedMessage) {
|
||||
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
|
||||
logger.info('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
|
||||
GlobalEventBus.emit('adb:raw-payload', trimmedMessage);
|
||||
handleAdbPayload(trimmedMessage);
|
||||
}
|
||||
|
|
@ -747,17 +836,18 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
handleAdbPayload(remainingMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('recipe menu read error', e);
|
||||
logger.error('recipe menu read error', e);
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Android recipe menu channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
if (retryOnFailure) {
|
||||
scheduleRecipeMenuAndroidServerReconnect();
|
||||
}
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
console.error('Recipe menu connection failed. Suspect java running or not', err);
|
||||
logger.error('Recipe menu connection failed. Suspect java running or not', err);
|
||||
adbWriter.set(null);
|
||||
if (notifyFailure) addNotification('ERR:Fail to enable Android recipe menu channel');
|
||||
if (retryOnFailure) {
|
||||
|
|
@ -772,3 +862,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
export function getScrcpyBinaryFromSource() {
|
||||
//https://github.com/Genymobile/scrcpy/releases
|
||||
}
|
||||
|
||||
// Initialize connection store based on existing instance
|
||||
// (handles hot-reload / page refresh where instance survives)
|
||||
if (AdbInstance.instance) {
|
||||
adbConnectionStatus.setConnected();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
||||
|
||||
export class DeviceCredentialManager {
|
||||
|
|
@ -20,9 +21,9 @@ export class DeviceCredentialManager {
|
|||
storedDevices[device.serial] = deviceInfo;
|
||||
|
||||
localStorage.setItem('adb_device_infos', JSON.stringify(storedDevices));
|
||||
console.log('save device info', deviceInfo);
|
||||
logger.info('save device info', deviceInfo);
|
||||
} catch (error) {
|
||||
console.error('save device info error', error);
|
||||
logger.error('save device info error', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ export class DeviceCredentialManager {
|
|||
const stored = localStorage.getItem('adb_device_infos');
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (error) {
|
||||
console.error('unable to get stored device info', error);
|
||||
logger.error('unable to get stored device info', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +44,7 @@ export class DeviceCredentialManager {
|
|||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('check stored keys fail', error);
|
||||
logger.error('check stored keys fail', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +58,7 @@ export class DeviceCredentialManager {
|
|||
|
||||
return count;
|
||||
} catch (error) {
|
||||
console.error('get key stored count error', error);
|
||||
logger.error('get key stored count error', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +75,7 @@ export class DeviceCredentialManager {
|
|||
try {
|
||||
clearedCount++;
|
||||
} catch (error) {
|
||||
console.error('clear error', error);
|
||||
logger.error('clear error', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,16 +89,16 @@ export class DeviceCredentialManager {
|
|||
request.onsuccess = () => resolve(null);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => {
|
||||
console.warn('request delete got blocked');
|
||||
logger.warn('request delete got blocked');
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
return clearedCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import { doc, getDoc } from 'firebase/firestore';
|
||||
import { db } from '../client/firebase';
|
||||
|
||||
export async function checkAllowAccess(userDomain: string): Promise<boolean> {
|
||||
const docRef = doc(db, 'whitelist', 'allowedDomains');
|
||||
const snapshot = await getDoc(docRef);
|
||||
|
||||
if (snapshot.exists()) {
|
||||
let domains = snapshot.data();
|
||||
return domains['account_email'].includes(userDomain);
|
||||
}
|
||||
|
||||
// Domain blocker disabled — authentication rework is in progress.
|
||||
// The domain changed from Google to others; this module will be rewritten.
|
||||
export async function checkAllowAccess(_userDomain: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
0
src/lib/core/brew/machineStatusDefinition.ts
Normal file
0
src/lib/core/brew/machineStatusDefinition.ts
Normal file
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get } from 'svelte/store';
|
||||
import { departmentStore } from '../stores/departments';
|
||||
import { sendMessage } from '../handlers/ws_messageSender';
|
||||
|
|
@ -10,7 +11,7 @@ import { recipeData, recipeOverviewData } from '../stores/recipeStore';
|
|||
|
||||
export async function getRecipes() {
|
||||
if (browser && !get(departmentStore)) {
|
||||
console.log('cannot get dep', get(departmentStore));
|
||||
logger.info('cannot get dep', get(departmentStore));
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export async function getRecipes() {
|
|||
// construct path. fetch (GET) {server}/recipe/{countryTarget}/{version}
|
||||
let idToken = await get(auth)?.getIdToken();
|
||||
|
||||
console.log('country target get recipe', countryTarget);
|
||||
logger.info('country target get recipe', countryTarget);
|
||||
|
||||
recipeData.set([]);
|
||||
recipeOverviewData.set([]);
|
||||
|
|
@ -53,7 +54,7 @@ export async function getRecipes() {
|
|||
|
||||
export async function getRecipeWithVersion(version: string) {
|
||||
if (browser && !get(departmentStore)) {
|
||||
console.log('cannot get dep', get(departmentStore));
|
||||
logger.info('cannot get dep', get(departmentStore));
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ export async function getRecipeWithVersion(version: string) {
|
|||
// construct path. fetch (GET) {server}/recipe/{countryTarget}/{version}
|
||||
let idToken = await get(auth)?.getIdToken();
|
||||
|
||||
console.log('country target get recipe', countryTarget);
|
||||
logger.info('country target get recipe', countryTarget);
|
||||
|
||||
recipeData.set([]);
|
||||
recipeOverviewData.set([]);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get } from 'svelte/store';
|
||||
import { updateMachineStatus } from '../stores/machineInfoStore';
|
||||
import { addNotification } from '../stores/noti';
|
||||
|
|
@ -19,13 +20,13 @@ 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));
|
||||
// logger.info('[ADB] Received payload:', raw_payload.slice(0, 300));
|
||||
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||
// const bus = useEventBus();
|
||||
|
||||
try {
|
||||
const payload: AdbPayload = JSON.parse(raw_payload);
|
||||
// console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
|
||||
// logger.info('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
|
||||
|
||||
// Emit payload event for terminal drawer payload viewer
|
||||
GlobalEventBus.emit('adb:payload', payload);
|
||||
|
|
@ -46,7 +47,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
let log_message = payload.payload['msg'] ?? '';
|
||||
|
||||
if (log_message !== '') {
|
||||
console.log('[ADB LOG]', log_level, log_message);
|
||||
logger.info('[ADB LOG]', log_level, log_message);
|
||||
addNotification(`${log_level}:${log_message}`);
|
||||
}
|
||||
break;
|
||||
|
|
@ -65,7 +66,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
let action = res[2] ?? '';
|
||||
let uiAction = res[3] ?? '';
|
||||
|
||||
console.log('[ADB] Save response parsed:', { pd, action, uiAction, raw_payload });
|
||||
// logger.info('[ADB] Save response parsed:', { pd, action, uiAction, raw_payload });
|
||||
|
||||
// Track menu save status
|
||||
if (raw_payload.startsWith('save_recipe_menu_file') && pd) {
|
||||
|
|
@ -93,6 +94,9 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
// }
|
||||
// })
|
||||
// );
|
||||
if (pd.length > 0) {
|
||||
setMenuSaved(pd);
|
||||
}
|
||||
}
|
||||
} else if (raw_payload.startsWith('state')) {
|
||||
let res = raw_payload.split('/');
|
||||
|
|
@ -108,7 +112,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
// acknowledge response from app
|
||||
if (payload.payload !== 'OK') {
|
||||
// abnormal
|
||||
console.error('error from ACK', payload.payload);
|
||||
logger.error('error from ACK', payload.payload);
|
||||
addNotification('ERR:Request rejected');
|
||||
}
|
||||
break;
|
||||
|
|
@ -119,7 +123,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
let curr = states[0].replace('MACHINE_STATE_', '');
|
||||
let next = states[1].replace('MACHINE_STATE_', '');
|
||||
|
||||
console.log('current state', curr, 'next state', next);
|
||||
logger.info('current state', curr, 'next state', next);
|
||||
|
||||
addNotification('INFO:Machine Status Updated, ' + next);
|
||||
updateMachineStatus(next);
|
||||
|
|
@ -133,7 +137,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
let mode_ref = plist[2] ?? '';
|
||||
|
||||
// update recipe data store
|
||||
console.log('brewing finish', pd, 'total time', total_time);
|
||||
logger.info('brewing finish', pd, 'total time', total_time);
|
||||
|
||||
// update recipe from brew now
|
||||
let recipeDevSnapshot = get(recipeFromMachineQuery) ?? {};
|
||||
|
|
@ -190,7 +194,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
// only update recipe from memory of brew app
|
||||
if (!semver.satisfies(APP_VERSION, '^0.0.3')) {
|
||||
// reject
|
||||
console.log('unsupported version');
|
||||
logger.info('unsupported version');
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -281,11 +285,11 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
} else if (sub_type == 'end' && payload.payload.includes('finish')) {
|
||||
let force_reload = payload.payload.includes('reload');
|
||||
|
||||
console.log('queued recipes: ', queuedPromises.length);
|
||||
logger.info('queued recipes: ', queuedPromises.length);
|
||||
if (queuedPromises.length > 0) {
|
||||
try {
|
||||
await Promise.all(queuedPromises);
|
||||
console.log('clear all recipe promises');
|
||||
logger.info('clear all recipe promises');
|
||||
queuedPromises = new Array();
|
||||
|
||||
GlobalEventBus.emitUntilConsumed('recipe-event', {
|
||||
|
|
@ -294,7 +298,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
reload: force_reload
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('some promise failed: ', e);
|
||||
logger.error('some promise failed: ', e);
|
||||
|
||||
GlobalEventBus.emitUntilConsumed('recipe-event', {
|
||||
type: 'load-recipe-fail',
|
||||
|
|
@ -323,13 +327,13 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
};
|
||||
}
|
||||
|
||||
console.log('checking add mat', recipeRawFromMachine);
|
||||
// logger.info('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);
|
||||
logger.info('receive topping', topping_raw);
|
||||
|
||||
//
|
||||
recipeRawFromMachine = {
|
||||
|
|
@ -344,7 +348,7 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
recipeFromMachine.set(recipeRawFromMachine);
|
||||
} else {
|
||||
// unhandled sub type
|
||||
console.log('unhandled sub type', payload);
|
||||
logger.info('unhandled sub type', payload);
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { addNotification, notiStore } from '../stores/noti';
|
||||
import {
|
||||
|
|
@ -21,11 +22,14 @@ import {
|
|||
handleSheetStreamEnd,
|
||||
handleSheetStreamError,
|
||||
handleCatalogsResponse,
|
||||
handlePriceSlotsResponse,
|
||||
isPriceSlotsPayload,
|
||||
handleListMenuResponse,
|
||||
sheetCatalogsLoading,
|
||||
handleRawStreamHeader,
|
||||
handleRawStreamChunk,
|
||||
handleRawStreamEnd
|
||||
handleRawStreamEnd,
|
||||
handleSheetPriceResponse
|
||||
} from '../stores/sheetStore';
|
||||
import {
|
||||
handleGenLayoutBatchStart,
|
||||
|
|
@ -48,9 +52,10 @@ import { auth as authStore } from '../stores/auth';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import * as semver from 'semver';
|
||||
import { WebCryptoHelper } from '../utils/crypto';
|
||||
import { GlobalEventBus } from '../utils/eventBus';
|
||||
import { handleIncomingRemoteShell } from '../stores/remoteShellStore';
|
||||
import * as semver from 'semver';
|
||||
|
||||
export const messages = writable<string[]>([]);
|
||||
|
||||
|
|
@ -59,8 +64,12 @@ type WSMessage = { type: string; payload: any };
|
|||
|
||||
// MAXIMUM LIMIT = 1814355
|
||||
const handlers: Record<string, (payload: any) => void> = {
|
||||
chat: (p) => messages.update((m) => [...m, p]),
|
||||
ping: (p) => console.log('ping from server'),
|
||||
chat: (p) =>
|
||||
messages.update((m) => {
|
||||
const next = [...m, p];
|
||||
return next.length > 200 ? next.slice(-200) : next;
|
||||
}),
|
||||
ping: (p) => logger.info('ping from server'),
|
||||
recipeResponse: (p) => {
|
||||
let recipe_result = p.result;
|
||||
let recipe_request = p.request;
|
||||
|
|
@ -109,7 +118,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
},
|
||||
stream_data_chunk: (p) => {
|
||||
let current_meta = get(recipeStreamMeta);
|
||||
// console.log('current meta', current_meta);
|
||||
// logger.info('current meta', current_meta);
|
||||
if (current_meta) {
|
||||
let stream_id = current_meta.id;
|
||||
|
||||
|
|
@ -146,7 +155,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
// build overview for recipe from server
|
||||
//
|
||||
|
||||
// console.log('ending stream');
|
||||
// logger.info('ending stream');
|
||||
buildOverviewFromServer();
|
||||
|
||||
let current_meta = get(recipeStreamMeta);
|
||||
|
|
@ -201,7 +210,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
curr_mat_query.push(m);
|
||||
}
|
||||
|
||||
// // console.log('current materials: ', JSON.stringify(curr_mat_query));
|
||||
// // logger.info('current materials: ', JSON.stringify(curr_mat_query));
|
||||
materialFromServerQuery.set(curr_mat_query);
|
||||
break;
|
||||
case 'topplist':
|
||||
|
|
@ -285,33 +294,83 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
addNotification(`ERR:Gen Layout error: ${msg}`);
|
||||
break;
|
||||
default:
|
||||
console.log('[GenService] Received:', level, msg);
|
||||
logger.info('[GenService] Received:', level, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (from === 'sheet-service' && level === 'content') {
|
||||
const currentUid = auth.currentUser?.uid;
|
||||
const content = p.content ?? p.value ?? p.payload;
|
||||
const ref = p.ref ?? '';
|
||||
|
||||
if (target && currentUid && target === currentUid) {
|
||||
if (!msg && p.content?.catalogs) {
|
||||
handleCatalogsResponse(p.content);
|
||||
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle streaming messages (with msg field)
|
||||
switch (msg) {
|
||||
case 'priceslot':
|
||||
case 'price_slot':
|
||||
handlePriceSlotsResponse(content);
|
||||
addNotification('INFO:Loaded PriceSlot data');
|
||||
break;
|
||||
case 'start':
|
||||
handleSheetStreamStart(p);
|
||||
addNotification('INFO:Sheet data streaming started');
|
||||
if (ref === 'price') {
|
||||
addNotification('INFO:Sheet price streaming started');
|
||||
} else {
|
||||
handleSheetStreamStart(p);
|
||||
addNotification('INFO:Sheet data streaming started');
|
||||
}
|
||||
break;
|
||||
case 'chunk':
|
||||
handleSheetStreamChunk(p);
|
||||
if (isPriceSlotsPayload(content)) {
|
||||
handlePriceSlotsResponse(content);
|
||||
} else if (ref === 'price') {
|
||||
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
|
||||
} else {
|
||||
handleSheetStreamChunk(p);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
handleSheetStreamEnd(p);
|
||||
addNotification('INFO:Sheet data streaming complete');
|
||||
if (ref === 'price') {
|
||||
addNotification('INFO:Sheet price streaming complete');
|
||||
} else {
|
||||
handleSheetStreamEnd(p);
|
||||
addNotification('INFO:Sheet data streaming complete');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
handleSheetStreamError(p);
|
||||
|
|
@ -319,8 +378,17 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
break;
|
||||
default:
|
||||
// Handle other content notifications from sheet-service
|
||||
console.log('[Sheet] Received content:', p.content);
|
||||
logger.info('[Sheet] Received content:', {
|
||||
contentItems: Array.isArray(content) ? content.length : undefined
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('[Sheet] Ignored content because target does not match current user:', {
|
||||
target,
|
||||
currentUid,
|
||||
msg,
|
||||
contentItems: Array.isArray(content) ? content.length : undefined
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -368,7 +436,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
|
||||
let content: RecipePrice[] = p.content ?? [];
|
||||
|
||||
console.log('get price length: ', content.length);
|
||||
logger.info('get price length: ', content.length);
|
||||
|
||||
let current_price = get(priceRecipeData);
|
||||
let lastRequestPriceInstance = get(lastRequestSheetPrice);
|
||||
|
|
@ -385,7 +453,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
|
||||
priceRecipeData.set(current_price);
|
||||
|
||||
console.log('check length', saved_product_code_to_get_from_sheet.length);
|
||||
logger.info('check length', saved_product_code_to_get_from_sheet.length);
|
||||
// set command request to stream mode so
|
||||
let request_id = uuidv4();
|
||||
|
||||
|
|
@ -398,6 +466,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
country: current_meta?.country ?? '',
|
||||
content: saved_product_code_to_get_from_sheet,
|
||||
param: 'price',
|
||||
option: 'price',
|
||||
stream: true,
|
||||
request_id
|
||||
});
|
||||
|
|
@ -450,7 +519,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
// streamingRawData.set(streamRawInstance);
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.log(`end price process error: ${e}`);
|
||||
// logger.info(`end price process error: ${e}`);
|
||||
// }
|
||||
|
||||
// break;
|
||||
|
|
@ -460,7 +529,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
heartbeat: (p) => {
|
||||
socketConnectionOfflineCount.set(0);
|
||||
socketAlreadySendHeartbeat.set(0);
|
||||
console.log('heartbeat reset offline count');
|
||||
logger.info('heartbeat reset offline count');
|
||||
},
|
||||
// Raw stream handlers for sheet data (e.g., price)
|
||||
raw_stream: (p) => {
|
||||
|
|
@ -475,27 +544,41 @@ 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);
|
||||
},
|
||||
remote_shell: (p) => {
|
||||
// Server requests command execution on connected Android device
|
||||
handleIncomingRemoteShell(p);
|
||||
}
|
||||
};
|
||||
|
||||
export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) {
|
||||
export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
|
||||
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||
const parsedMessage = JSON.parse(raw);
|
||||
|
||||
const ack: HandshakeAck = JSON.parse(raw);
|
||||
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
|
||||
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));
|
||||
|
||||
|
|
@ -503,35 +586,40 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey: Cryp
|
|||
|
||||
return;
|
||||
}
|
||||
if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
|
||||
if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
|
||||
// secured message decryption
|
||||
let sharedKeyStore = get(sharedKey);
|
||||
if (sharedKeyStore) {
|
||||
let raw_payload = JSON.parse(raw);
|
||||
let decrypted_string = await WebCryptoHelper.decryptMessage(
|
||||
sharedKeyStore,
|
||||
raw_payload.ciphertext,
|
||||
raw_payload.iv
|
||||
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 = JSON.parse(raw);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get } from 'svelte/store';
|
||||
import { lastRequestSheetPrice } from '../stores/recipeStore';
|
||||
|
||||
|
|
@ -37,11 +38,11 @@ export function handleSheetResponseFromNoti(raw_payload: any, ref: string, count
|
|||
switch (ref) {
|
||||
case 'price':
|
||||
let price_contents: PayloadFromSheet[] = raw_payload.content;
|
||||
console.log(`price content length: ${price_contents.length}`);
|
||||
logger.info(`price content length: ${price_contents.length}`);
|
||||
let header_idx = PRICE_SHEET_DEFINITION_BY_COUNTRY[country ?? 'unknown'].get_header_idx(
|
||||
price_contents[0].header
|
||||
);
|
||||
console.log(`header idx: ${header_idx}`);
|
||||
logger.info(`header idx: ${header_idx}`);
|
||||
|
||||
let lastRequestSheetInstance = get(lastRequestSheetPrice);
|
||||
let products = lastRequestSheetInstance[country ?? 'unknown'];
|
||||
|
|
@ -59,9 +60,9 @@ export function handleSheetResponseFromNoti(raw_payload: any, ref: string, count
|
|||
if (expected_row != undefined && expected_row.cells != undefined) {
|
||||
let price_col = expected_row.cells[price_idx];
|
||||
products[curr_product_code] = price_col;
|
||||
console.log(`[handleSheetPrice][country] ${curr_product_code} --> ${price_col}`);
|
||||
logger.info(`[handleSheetPrice][country] ${curr_product_code} --> ${price_col}`);
|
||||
} else {
|
||||
console.log(
|
||||
logger.info(
|
||||
`[handleSheetPrice][country] ${curr_product_code} not found cell, ${JSON.stringify(price_rows)}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import type { OutMessage } from '../types/outMessage';
|
||||
import { sharedKey, socketStore } from '../stores/websocketStore';
|
||||
import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
|
||||
import { addNotification } from '../stores/noti';
|
||||
import { auth } from '../stores/auth';
|
||||
import { WebCryptoHelper } from '../utils/crypto';
|
||||
import * as semver from 'semver';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import * as semver from 'semver';
|
||||
|
||||
export const queue = writable<string[]>([]);
|
||||
|
||||
|
|
@ -22,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let srv_name = getServiceName(target);
|
||||
let curr_user = get(auth);
|
||||
|
||||
|
|
@ -54,10 +87,10 @@ export async function sendMessage(
|
|||
const socket = get(socketStore);
|
||||
let data = JSON.stringify(msg);
|
||||
|
||||
// console.log('try sending ', data);
|
||||
// logger.info('try sending ', data);
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
// console.warn('WebSocket not connected, put to queue');
|
||||
// logger.warn('WebSocket not connected, put to queue');
|
||||
|
||||
// let currentQueue = get(queue);
|
||||
// if (currentQueue.length >= 10) {
|
||||
|
|
@ -73,10 +106,10 @@ export async function sendMessage(
|
|||
return false;
|
||||
}
|
||||
|
||||
// console.log('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2'));
|
||||
// logger.info('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2'));
|
||||
|
||||
if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
|
||||
// console.log('sending secured');
|
||||
// logger.info('sending secured');
|
||||
let sharedKeyRes = get(sharedKey);
|
||||
|
||||
// do encrypt
|
||||
|
|
@ -84,6 +117,13 @@ export async function sendMessage(
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ export async function loadCachedAndroidRecipeExport(): Promise<AndroidRecipeExpo
|
|||
androidRecipeExportPayload.set(payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('failed to load cached android recipe export from IndexedDB', error);
|
||||
logger.error('failed to load cached android recipe export from IndexedDB', error);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -123,7 +124,7 @@ export async function loadCachedAndroidRecipeExport(): Promise<AndroidRecipeExpo
|
|||
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('failed to load legacy android recipe export cache', error);
|
||||
logger.error('failed to load legacy android recipe export cache', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +135,7 @@ export function saveAndroidRecipeExportPayload(payload: AndroidRecipeExportPaylo
|
|||
if (!browser) return;
|
||||
|
||||
void writeCachedPayloadToIndexedDb(payload).catch((error) => {
|
||||
console.error('failed to cache android recipe export', error);
|
||||
logger.error('failed to cache android recipe export', error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +146,7 @@ export function clearCachedAndroidRecipeExport() {
|
|||
|
||||
localStorage.removeItem(ANDROID_RECIPE_EXPORT_CACHE_KEY);
|
||||
void deleteCachedPayloadFromIndexedDb().catch((error) => {
|
||||
console.error('failed to clear android recipe export cache', error);
|
||||
logger.error('failed to clear android recipe export cache', error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { sendCommandRequest, sendMessage } from '../handlers/ws_messageSender';
|
||||
import { get } from 'svelte/store';
|
||||
import { auth } from '../stores/auth';
|
||||
|
|
@ -7,10 +8,18 @@ import {
|
|||
markSheetPriceAsSent,
|
||||
sheetPriceLoading,
|
||||
streamingRawData,
|
||||
setPendingProductCodesCountry
|
||||
setPendingProductCodesCountry,
|
||||
setPendingPriceSlotsCountry,
|
||||
priceSlotsLoading,
|
||||
resetPriceSlotsCountry
|
||||
} 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', {
|
||||
country: country,
|
||||
|
|
@ -18,27 +27,179 @@ export async function requestCatalogs(country: string): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function requestPriceSlots(country: string): Promise<boolean> {
|
||||
/**
|
||||
* 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', {
|
||||
country: country,
|
||||
param: 'priceslot'
|
||||
catalog: catalog,
|
||||
catalog_name: catalogName,
|
||||
param: 'add/catalog'
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
export async function refreshPriceSlotList(country: string): Promise<boolean> {
|
||||
return requestPriceSlotOption(country, 'PriceSlot');
|
||||
}
|
||||
|
||||
export async function updatePriceSlot(
|
||||
country: string,
|
||||
content: {
|
||||
slot: number;
|
||||
name: string;
|
||||
description: string;
|
||||
products: { product_code: string; price: number | null; row_index?: number }[];
|
||||
}
|
||||
slot: PriceSlot,
|
||||
content: SheetRowUpdate[]
|
||||
): Promise<boolean> {
|
||||
return await sendCommandRequest('sheet', {
|
||||
// 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', {
|
||||
country: country,
|
||||
content: content,
|
||||
param: 'update/priceslot'
|
||||
param: 'update/price',
|
||||
option: `PriceSlot${slot.slot}`
|
||||
});
|
||||
|
||||
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> {
|
||||
|
|
@ -87,14 +248,14 @@ export async function updateMenu(
|
|||
}
|
||||
|
||||
export async function addMenu(country: string, catalog: string, content: any[]): Promise<boolean> {
|
||||
console.log('[sheetService] Adding menu:', { country, catalog, content });
|
||||
logger.info('[sheetService] Adding menu:', { country, catalog, content });
|
||||
const sent = await sendCommandRequest('sheet', {
|
||||
country: country,
|
||||
catalog: catalog,
|
||||
content: content,
|
||||
param: 'add/menu'
|
||||
});
|
||||
console.log('[sheetService] Add menu sent:', sent);
|
||||
logger.info('[sheetService] Add menu sent:', sent);
|
||||
return sent;
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +301,7 @@ export async function requestListMenu(country: string, boxid?: string): Promise<
|
|||
productCodesLoading.set(true);
|
||||
setPendingProductCodesCountry(country);
|
||||
|
||||
console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
|
||||
logger.info('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
|
||||
|
||||
return await sendMessage({
|
||||
type: 'list_menu',
|
||||
|
|
@ -166,7 +327,7 @@ export async function requestGenLayout(country: string): Promise<boolean> {
|
|||
|
||||
setGenLayoutGenerating();
|
||||
|
||||
console.log('[sheetService] Sending gen-layout request for country:', country);
|
||||
logger.info('[sheetService] Sending gen-layout request for country:', country);
|
||||
|
||||
return await sendMessage({
|
||||
type: 'command',
|
||||
|
|
@ -187,15 +348,19 @@ 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[]): Promise<boolean> {
|
||||
export async function requestSheetPrice(
|
||||
country: string,
|
||||
productCodes: string[],
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
// Check if already sent
|
||||
if (hasSheetPriceBeenSent('price')) {
|
||||
console.warn('[sheetService] Price request already sent, skipping');
|
||||
if (!force && hasSheetPriceBeenSent('price')) {
|
||||
logger.warn('[sheetService] Price request already sent, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!productCodes || productCodes.length === 0) {
|
||||
console.warn('[sheetService] No product codes to request price for');
|
||||
logger.warn('[sheetService] No product codes to request price for');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +383,7 @@ export async function requestSheetPrice(country: string, productCodes: string[])
|
|||
// Convert to array of objects (backend expects objects, not strings)
|
||||
const content = productCodes.map((code) => ({ product_code: code }));
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[sheetService] Sending sheet price request for country:',
|
||||
country,
|
||||
'codes:',
|
||||
|
|
@ -231,6 +396,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
|
|||
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
|
||||
});
|
||||
|
|
@ -253,11 +460,11 @@ export async function updateSheetPrice(
|
|||
content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[]
|
||||
): Promise<boolean> {
|
||||
if (!content || content.length === 0) {
|
||||
console.warn('[sheetService] No content to update');
|
||||
logger.warn('[sheetService] No content to update');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[sheetService] Updating sheet price for country:',
|
||||
country,
|
||||
'items:',
|
||||
|
|
@ -280,11 +487,11 @@ export async function addSheetPrice(
|
|||
content: { cells: string[] }[]
|
||||
): Promise<boolean> {
|
||||
if (!content || content.length === 0) {
|
||||
console.warn('[sheetService] No content to add');
|
||||
logger.warn('[sheetService] No content to add');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[sheetService] Adding price rows for country:',
|
||||
country,
|
||||
'items:',
|
||||
|
|
|
|||
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
54
src/lib/core/stores/adbConnectionStore.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Possible states for the ADB (WebUSB) device connection.
|
||||
*
|
||||
* - disconnected: No device is currently connected.
|
||||
* - connecting: A connection attempt is in progress.
|
||||
* - connected: A device is connected and the Adb instance is valid.
|
||||
*/
|
||||
export type AdbConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
|
||||
function createAdbConnectionStore() {
|
||||
const { subscribe, set } = writable<AdbConnectionStatus>('disconnected');
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setConnected: () => set('connected'),
|
||||
setConnecting: () => set('connecting'),
|
||||
setDisconnected: () => set('disconnected'),
|
||||
/**
|
||||
* Reset to the disconnected state.
|
||||
*/
|
||||
reset: () => set('disconnected')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive store that tracks the current ADB connection status.
|
||||
*
|
||||
* Components can subscribe to this store and reactively update their UI
|
||||
* whenever the connection state changes (e.g. show/hide connect buttons,
|
||||
* enable/disable features).
|
||||
*
|
||||
* The store is updated by adb.ts internals — pages and components should
|
||||
* never call setConnected / setDisconnected directly; they should use the
|
||||
* exported `ensureAdbConnection()` or `disconnect()` functions from adb.ts.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* import { adbConnectionStatus, isAdbConnected } from '$lib/core/stores/adbConnectionStore';
|
||||
*
|
||||
* // Reactive boolean — true when connected
|
||||
* let connected = $derived($isAdbConnected);
|
||||
*
|
||||
* // Or subscribe manually
|
||||
* $: console.log('adb status', $adbConnectionStatus);
|
||||
* ```
|
||||
*/
|
||||
export const adbConnectionStatus = createAdbConnectionStore();
|
||||
|
||||
/**
|
||||
* Derived store that emits `true` when the ADB device is connected.
|
||||
*/
|
||||
export const isAdbConnected = derived(adbConnectionStatus, ($status) => $status === 'connected');
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { addNotification } from './noti';
|
||||
|
||||
|
|
@ -5,7 +6,7 @@ const adbWriter: any = writable(null);
|
|||
|
||||
async function sendToAndroid(message: any) {
|
||||
let writer: any = get(adbWriter);
|
||||
console.log('writer', writer);
|
||||
// logger.info('writer', writer);
|
||||
if (!writer) {
|
||||
addNotification('ERR:No active Android connection');
|
||||
return false;
|
||||
|
|
@ -14,18 +15,18 @@ async function sendToAndroid(message: any) {
|
|||
const encoder = new TextEncoder();
|
||||
const serializedMessage = JSON.stringify(message);
|
||||
await writer.write(encoder.encode(serializedMessage + '\n'));
|
||||
console.log('[ADB] sent', {
|
||||
type: message?.type,
|
||||
bytes: serializedMessage.length,
|
||||
productCode: message?.payload?.data?.productCode,
|
||||
batchCount: Array.isArray(message?.payload?.data) ? message.payload.data.length : undefined,
|
||||
batchProductCodes: Array.isArray(message?.payload?.data)
|
||||
? message.payload.data.map((menu: any) => menu?.productCode)
|
||||
: undefined
|
||||
});
|
||||
// logger.info('[ADB] sent', {
|
||||
// type: message?.type,
|
||||
// bytes: serializedMessage.length,
|
||||
// productCode: message?.payload?.data?.productCode,
|
||||
// batchCount: Array.isArray(message?.payload?.data) ? message.payload.data.length : undefined,
|
||||
// batchProductCodes: Array.isArray(message?.payload?.data)
|
||||
// ? message.payload.data.map((menu: any) => menu?.productCode)
|
||||
// : undefined
|
||||
// });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('write failed', error);
|
||||
logger.error('write failed', error);
|
||||
addNotification('ERR:Failed to send message to Android');
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
export interface GenLayoutFile {
|
||||
|
|
@ -59,7 +60,7 @@ export function handleGenLayoutBatchStart(payload: {
|
|||
status: 'receiving',
|
||||
files: []
|
||||
});
|
||||
console.log('[GenLayout] Batch started:', payload.batch_id, 'total files:', payload.total_files);
|
||||
logger.info('[GenLayout] Batch started:', payload.batch_id, 'total files:', payload.total_files);
|
||||
}
|
||||
|
||||
export function handleGenLayoutFile(payload: {
|
||||
|
|
@ -96,7 +97,7 @@ export function handleGenLayoutFile(payload: {
|
|||
// Store this chunk
|
||||
tracker.parts.set(partIndex, payload.content);
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[GenLayout] Received chunk:',
|
||||
partIndex + 1,
|
||||
'/',
|
||||
|
|
@ -127,7 +128,7 @@ export function handleGenLayoutFile(payload: {
|
|||
// Clean up tracker
|
||||
chunkedFiles.delete(fileIndex);
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[GenLayout] Assembled chunked file:',
|
||||
fileIndex + 1,
|
||||
'/',
|
||||
|
|
@ -143,7 +144,7 @@ export function handleGenLayoutFile(payload: {
|
|||
file_index: payload.file_index
|
||||
}, payload.total_files);
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[GenLayout] Received file:',
|
||||
payload.file_index + 1,
|
||||
'/',
|
||||
|
|
@ -191,7 +192,7 @@ export function handleGenLayoutBatchEnd(payload: { batch_id: string; total_files
|
|||
files: sortedFiles,
|
||||
error
|
||||
}));
|
||||
console.warn('[GenLayout] Batch incomplete:', error, sortedFiles);
|
||||
logger.warn('[GenLayout] Batch incomplete:', error, sortedFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +203,7 @@ export function handleGenLayoutBatchEnd(payload: { batch_id: string; total_files
|
|||
files: sortedFiles
|
||||
}));
|
||||
|
||||
console.log('[GenLayout] Batch complete, received', sortedFiles.length, 'files');
|
||||
logger.info('[GenLayout] Batch complete, received', sortedFiles.length, 'files');
|
||||
|
||||
if (onBatchCompleteCallback) {
|
||||
onBatchCompleteCallback(sortedFiles);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import { get, writable } from 'svelte/store';
|
|||
// save notifications to user
|
||||
export const notiStore = writable<string[]>([]);
|
||||
|
||||
const MAX_NOTI_SIZE = 1000;
|
||||
|
||||
export function addNotification(msg: string) {
|
||||
let current = get(notiStore);
|
||||
current.push(msg);
|
||||
if (current.length > MAX_NOTI_SIZE) {
|
||||
current = current.slice(-MAX_NOTI_SIZE);
|
||||
}
|
||||
notiStore.set(current);
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +21,7 @@ export function getNotification() {
|
|||
if (first) {
|
||||
let msg_p = first.split(':');
|
||||
let msg_level_type = msg_p[0];
|
||||
let msg = msg_p[1];
|
||||
let msg = msg_p.slice(1).join('');
|
||||
|
||||
switch (msg_level_type) {
|
||||
case 'ERR':
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ export const toppingGroupFromServerQuery = writable<any>([]);
|
|||
export const latestRecipeToppingData = writable<any>([]);
|
||||
|
||||
// edit data update
|
||||
/// NOTE: Will be obsolete in future, and replace with `EventBus` style.
|
||||
/// NOTE: DEPRECATED — replaced by `GlobalEventBus` + recipe-editor-events.ts.
|
||||
/// All components now use the typed event bus pattern.
|
||||
/// Kept only to avoid breaking any external references.
|
||||
export const recipeDataEvent = writable<{
|
||||
event_type: string;
|
||||
payload: any;
|
||||
|
|
|
|||
294
src/lib/core/stores/remoteShellStore.ts
Normal file
294
src/lib/core/stores/remoteShellStore.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { get, writable } from 'svelte/store';
|
||||
import { executeCmd, executeStreamingCmd, getAdbInstance } from '../adb/adb';
|
||||
import { sendMessage } from '../handlers/ws_messageSender';
|
||||
import { addNotification } from './noti';
|
||||
|
||||
// Commands that modify device state — always require explicit user confirmation.
|
||||
const DANGEROUS_COMMAND_PREFIXES = [
|
||||
'rm', 'reboot', 'reboot-bootloader', 'flash', 'format',
|
||||
'mount', 'unmount', 'dd', 'mkfs', 'wipe',
|
||||
'install', 'uninstall', 'pm ', 'am ', 'svc ',
|
||||
];
|
||||
|
||||
function isDangerousCommand(command: string): boolean {
|
||||
const trimmed = command.trim().toLowerCase();
|
||||
// Block shell metacharacters that enable multi-command injection
|
||||
if (/[;&|`$(){}[\]<>!\\]/.test(trimmed)) return true;
|
||||
return DANGEROUS_COMMAND_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an incoming remote-shell request from the WebSocket server
|
||||
* asking the client to execute a command on the connected Android device.
|
||||
*/
|
||||
export interface RemoteShellRequest {
|
||||
requestId: string;
|
||||
commandInput: string;
|
||||
purposeDeclaration: string;
|
||||
userInfo: any;
|
||||
status: 'pending' | 'confirmed' | 'rejected' | 'executing' | 'completed' | 'failed';
|
||||
output?: string;
|
||||
error?: string;
|
||||
exitCode?: number;
|
||||
timestamp: number;
|
||||
/** Optional timeout in seconds from the server. 0 or undefined = no timeout. */
|
||||
timeoutSeconds?: number;
|
||||
/** True if the command was aborted by timeout rather than natural completion. */
|
||||
timedOut?: boolean;
|
||||
/** True if the command is flagged as dangerous and requires explicit user confirmation. */
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive store holding the current pending/active remote-shell request.
|
||||
* null when no request is active.
|
||||
*/
|
||||
export const remoteShellRequest = writable<RemoteShellRequest | null>(null);
|
||||
|
||||
/**
|
||||
* Called by the WebSocket message handler when a 'remote-shell' message arrives.
|
||||
* If requireConfirmation is true, sets status to 'pending' — the UI will
|
||||
* prompt the user. Otherwise, immediately executes the command.
|
||||
*/
|
||||
export function handleIncomingRemoteShell(payload: any) {
|
||||
let needsConfirm = payload.requireConfirmation === true;
|
||||
const rawTimeout = payload.timeout;
|
||||
const timeoutSeconds =
|
||||
rawTimeout != null && typeof rawTimeout === 'number' && rawTimeout > 0 ? rawTimeout : undefined;
|
||||
|
||||
const commandInput = payload.commandInput || '';
|
||||
const dangerous = isDangerousCommand(commandInput);
|
||||
|
||||
// Dangerous commands always require explicit user confirmation
|
||||
if (dangerous) {
|
||||
needsConfirm = true;
|
||||
}
|
||||
|
||||
const req: RemoteShellRequest = {
|
||||
requestId: payload.generatedRequestId,
|
||||
commandInput,
|
||||
purposeDeclaration: payload.purposeDeclaration || '',
|
||||
userInfo: payload.userInfo,
|
||||
status: needsConfirm ? 'pending' : 'confirmed',
|
||||
timestamp: Date.now(),
|
||||
timeoutSeconds,
|
||||
dangerous
|
||||
};
|
||||
|
||||
remoteShellRequest.set(req);
|
||||
|
||||
if (dangerous) {
|
||||
addNotification(
|
||||
'WARN:Remote shell command requires your approval – command will not run unless you accept'
|
||||
);
|
||||
} else if (needsConfirm) {
|
||||
addNotification('INFO:Remote shell command requires your approval');
|
||||
}
|
||||
|
||||
if (!needsConfirm) {
|
||||
const suffix = timeoutSeconds ? ` (timeout: ${timeoutSeconds}s)` : '';
|
||||
addNotification(`INFO:Remote shell command received – executing automatically${suffix}`);
|
||||
executeAndRespond(req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the UI when the user confirms they want to run the command.
|
||||
*/
|
||||
export function confirmRemoteShell() {
|
||||
const req = get(remoteShellRequest);
|
||||
if (!req || req.status !== 'pending') return;
|
||||
|
||||
addNotification('INFO:Remote shell command confirmed by user');
|
||||
remoteShellRequest.set({ ...req, status: 'confirmed' });
|
||||
executeAndRespond({ ...req, status: 'confirmed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the UI when the user rejects the command.
|
||||
*/
|
||||
export function rejectRemoteShell() {
|
||||
const req = get(remoteShellRequest);
|
||||
if (!req || req.status !== 'pending') return;
|
||||
|
||||
addNotification('WARN:Remote shell command rejected by user');
|
||||
remoteShellRequest.set(null);
|
||||
sendResponse(req.requestId, false, '', 'User rejected the command', -1, req.userInfo, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the ADB shell command and send the response back to the server.
|
||||
* Uses executeStreamingCmd with an optional timeout to handle long-running
|
||||
* commands like `logcat` that would otherwise never complete.
|
||||
*/
|
||||
async function executeAndRespond(req: RemoteShellRequest) {
|
||||
remoteShellRequest.set({ ...req, status: 'executing' });
|
||||
|
||||
// Check ADB connection
|
||||
const instance = getAdbInstance();
|
||||
if (!instance) {
|
||||
addNotification('ERR:No Android device connected for remote shell');
|
||||
remoteShellRequest.set({
|
||||
...req,
|
||||
status: 'failed',
|
||||
error: 'No Android device connected',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
sendResponse(req.requestId, false, '', 'No Android device connected', -1, req.userInfo, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide which execution path to use
|
||||
if (req.timeoutSeconds && req.timeoutSeconds > 0) {
|
||||
await executeWithTimeout(req);
|
||||
} else {
|
||||
await executeDirect(req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command that is expected to complete on its own.
|
||||
* Uses the simpler executeCmd() which properly captures exitCode and stderr.
|
||||
*/
|
||||
async function executeDirect(req: RemoteShellRequest) {
|
||||
const result = await executeCmd(req.commandInput);
|
||||
const output = result.output ?? '';
|
||||
const error = result.error ?? '';
|
||||
const exitCode = result.exitCode ?? (error ? 1 : 0);
|
||||
const success = exitCode === 0;
|
||||
|
||||
addNotification(`${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`);
|
||||
|
||||
remoteShellRequest.set({
|
||||
...req,
|
||||
status: success ? 'completed' : 'failed',
|
||||
output,
|
||||
error,
|
||||
exitCode,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
sendResponse(req.requestId, success, output, error, exitCode, req.userInfo, false);
|
||||
scheduleAutoClear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command with a timeout using executeStreamingCmd().
|
||||
* The streaming variant supports abort via its cleanup function, making it
|
||||
* suitable for long-running commands like `logcat`.
|
||||
*
|
||||
* When the timeout fires, the stream is aborted and whatever output was
|
||||
* captured so far is returned with timedOut=true.
|
||||
*/
|
||||
async function executeWithTimeout(req: RemoteShellRequest) {
|
||||
const timeoutMs = req.timeoutSeconds! * 1000;
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
let exitCode: number | undefined;
|
||||
let timedOut = false;
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = await executeStreamingCmd(req.commandInput, {
|
||||
onData: (chunk: string) => {
|
||||
output += chunk;
|
||||
},
|
||||
onError: (err: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
error = err;
|
||||
exitCode = 1;
|
||||
finish(req, output, error, exitCode, false);
|
||||
},
|
||||
onExit: (code: number | undefined) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
exitCode = code ?? 0;
|
||||
finish(req, output, error, exitCode, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Set the timeout timer
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
timedOut = true;
|
||||
cleanup(); // Abort the ADB stream
|
||||
addNotification(`WARN:Remote shell command timed out after ${req.timeoutSeconds}s`);
|
||||
finish(req, output, 'Command timed out after ' + req.timeoutSeconds + 's', -1, true);
|
||||
}, timeoutMs);
|
||||
|
||||
// If the command completes naturally, cancel the timeout timer
|
||||
const originalFinish = finish;
|
||||
function finish(
|
||||
r: RemoteShellRequest,
|
||||
out: string,
|
||||
err: string,
|
||||
code: number,
|
||||
timedOut: boolean
|
||||
) {
|
||||
clearTimeout(timeoutId);
|
||||
const success = !timedOut && code === 0;
|
||||
|
||||
addNotification(
|
||||
timedOut
|
||||
? 'WARN:Remote shell command timed out'
|
||||
: `${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`
|
||||
);
|
||||
|
||||
remoteShellRequest.set({
|
||||
...r,
|
||||
status: success ? 'completed' : 'failed',
|
||||
output: out,
|
||||
error: err,
|
||||
exitCode: code,
|
||||
timestamp: Date.now(),
|
||||
timedOut
|
||||
});
|
||||
|
||||
sendResponse(r.requestId, success, out, err, code, r.userInfo, timedOut);
|
||||
scheduleAutoClear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-clear the store after 8 seconds so the UI result state is visible.
|
||||
*/
|
||||
let autoClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function scheduleAutoClear() {
|
||||
clearTimeout(autoClearTimer);
|
||||
autoClearTimer = setTimeout(() => {
|
||||
const current = get(remoteShellRequest);
|
||||
if (current && (current.status === 'completed' || current.status === 'failed')) {
|
||||
remoteShellRequest.set(null);
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the execution result back to the WebSocket server.
|
||||
*/
|
||||
async function sendResponse(
|
||||
requestId: string,
|
||||
success: boolean,
|
||||
output: string,
|
||||
error: string,
|
||||
exitCode: number,
|
||||
userInfo: any,
|
||||
timedOut: boolean
|
||||
) {
|
||||
await sendMessage({
|
||||
type: 'remote-shell-response',
|
||||
payload: {
|
||||
requestId,
|
||||
success,
|
||||
output,
|
||||
error,
|
||||
exitCode,
|
||||
userInfo,
|
||||
timestamp: Date.now(),
|
||||
...(timedOut ? { timedOut: true } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
// Catalog types
|
||||
|
|
@ -24,16 +25,304 @@ 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',
|
||||
|
|
@ -78,11 +367,19 @@ 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, {
|
||||
language: Record<string, number>;
|
||||
productCode: { hot: number; cold: number; blend: number };
|
||||
primaryLanguage: string;
|
||||
}> = {
|
||||
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;
|
||||
}
|
||||
> = {
|
||||
tha: {
|
||||
language: { en: 3, th: 4, zh: 5, my: 8 },
|
||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||
|
|
@ -90,6 +387,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
|||
},
|
||||
aus: {
|
||||
language: { en: 3, th: 4 },
|
||||
nameDescLanguage: { en: 3, th: 4, ms: 7 },
|
||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||
primaryLanguage: 'en'
|
||||
},
|
||||
|
|
@ -100,11 +398,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
|||
},
|
||||
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'
|
||||
},
|
||||
|
|
@ -130,6 +430,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
|||
},
|
||||
sgp: {
|
||||
language: { en: 3, th: 4 },
|
||||
nameDescLanguage: { en: 3 },
|
||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||
primaryLanguage: 'en'
|
||||
},
|
||||
|
|
@ -151,8 +452,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
|||
};
|
||||
|
||||
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) {
|
||||
|
|
@ -304,10 +607,13 @@ 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']
|
||||
|
|
@ -366,7 +672,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
|
|||
// 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;
|
||||
|
|
@ -382,7 +688,9 @@ 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(
|
||||
|
|
@ -390,31 +698,45 @@ export function getPriceFromCells(
|
|||
cells: GristCell[],
|
||||
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
|
||||
): string | null {
|
||||
const headers = get(sheetPriceHeader)[country];
|
||||
if (!headers || headers.length === 0) {
|
||||
console.warn(`[getPriceFromCells] No header found for country: ${country}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get possible header names for this country
|
||||
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||||
const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
|
||||
|
||||
// Find the column index for this price type
|
||||
const colIdx = findHeaderIndex(headers, possibleNames);
|
||||
//console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
||||
|
||||
if (colIdx < 0) {
|
||||
console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
//console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
|
||||
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) {
|
||||
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get possible header names for this country
|
||||
const headerNames =
|
||||
PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||||
const possibleNames =
|
||||
priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
|
||||
|
||||
// Find the column index for this price type
|
||||
const colIdx = findHeaderIndex(headers, possibleNames);
|
||||
//logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
||||
|
||||
if (colIdx < 0) {
|
||||
logger.warn(
|
||||
`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
|
||||
possibleNames
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return colIdx;
|
||||
}
|
||||
|
||||
// Store for tracking streaming state
|
||||
export const sheetPriceStreamMeta = writable<{
|
||||
request_id: string;
|
||||
|
|
@ -444,15 +766,20 @@ export const streamingRawData = writable<
|
|||
|
||||
// Handler: raw_stream header (e.g., raw_stream_price)
|
||||
export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||
console.log(`[RawStream] Header for ${subtype}:`, payload);
|
||||
let targetSubtype = subtype;
|
||||
const currentData = get(streamingRawData);
|
||||
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
|
||||
targetSubtype = 'priceslot';
|
||||
}
|
||||
|
||||
logger.info(`[RawStream] Header for ${targetSubtype}:`, payload);
|
||||
|
||||
// Get existing stream data to preserve country from request
|
||||
const currentData = get(streamingRawData);
|
||||
const existingData = currentData[subtype];
|
||||
const existingData = currentData[targetSubtype];
|
||||
|
||||
streamingRawData.update((data) => ({
|
||||
...data,
|
||||
[subtype]: {
|
||||
[targetSubtype]: {
|
||||
request_id: payload.request_id,
|
||||
header: payload.header || payload.headers,
|
||||
country: payload.country || existingData?.country || '',
|
||||
|
|
@ -461,7 +788,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
|
|||
}
|
||||
}));
|
||||
|
||||
if (subtype === 'price') {
|
||||
if (targetSubtype === 'price') {
|
||||
sheetPriceStreamMeta.set({
|
||||
request_id: payload.request_id,
|
||||
country: payload.country || existingData?.country || '',
|
||||
|
|
@ -473,13 +800,21 @@ 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);
|
||||
const streamData = currentData[subtype];
|
||||
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];
|
||||
|
||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||
console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
|
||||
logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -488,13 +823,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
|||
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
|
||||
streamingRawData.update((data) => ({
|
||||
...data,
|
||||
[subtype]: {
|
||||
[targetSubtype]: {
|
||||
...streamData,
|
||||
country: payload.country || streamData.country,
|
||||
rawParts: [...(streamData.rawParts || []), payload.raw]
|
||||
}
|
||||
}));
|
||||
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
|
||||
logger.info(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -504,25 +839,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
|||
|
||||
streamingRawData.update((data) => ({
|
||||
...data,
|
||||
[subtype]: {
|
||||
[targetSubtype]: {
|
||||
...streamData,
|
||||
country: payload.country || streamData.country,
|
||||
chunks: [...streamData.chunks, ...contentArray]
|
||||
}
|
||||
}));
|
||||
|
||||
console.log(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`);
|
||||
logger.info(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`);
|
||||
}
|
||||
|
||||
// Handler: raw_stream end (e.g., raw_stream_end_price)
|
||||
export function handleRawStreamEnd(subtype: string, payload: any) {
|
||||
console.log(`[RawStream] End payload for ${subtype}:`, payload);
|
||||
|
||||
const currentData = get(streamingRawData);
|
||||
const streamData = currentData[subtype];
|
||||
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];
|
||||
|
||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||
console.warn(`[RawStream] End received for unknown stream: ${subtype}`);
|
||||
logger.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -533,13 +873,13 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
|
|||
let chunks = streamData.chunks || [];
|
||||
if (streamData.rawParts && streamData.rawParts.length > 0) {
|
||||
const fullRawJson = streamData.rawParts.join('');
|
||||
console.log(
|
||||
logger.info(
|
||||
`[RawStream] Joining ${streamData.rawParts.length} raw parts, total length: ${fullRawJson.length}`
|
||||
);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fullRawJson);
|
||||
console.log(`[RawStream] Parsed combined raw data, keys:`, Object.keys(parsed));
|
||||
logger.info(`[RawStream] Parsed combined raw data, keys:`, Object.keys(parsed));
|
||||
|
||||
// Extract content from nested structure: { payload: { content: [...] } }
|
||||
const content = parsed?.payload?.content || parsed?.content || parsed || [];
|
||||
|
|
@ -547,36 +887,59 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
|
|||
|
||||
// Log first item to see actual structure
|
||||
if (chunks.length > 0) {
|
||||
console.log(`[RawStream] First content item:`, JSON.stringify(chunks[0]).substring(0, 200));
|
||||
logger.info(`[RawStream] First content item:`, JSON.stringify(chunks[0]).substring(0, 200));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[RawStream] Failed to parse combined raw JSON:`, e);
|
||||
logger.error(`[RawStream] Failed to parse combined raw JSON:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
|
||||
logger.info(
|
||||
`[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}`
|
||||
);
|
||||
|
||||
if (subtype === 'price') {
|
||||
processSheetPriceData(country, streamData.header || [], chunks);
|
||||
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
|
||||
sheetPriceLoading.set(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the streaming data
|
||||
streamingRawData.update((data) => {
|
||||
const newData = { ...data };
|
||||
delete newData[subtype];
|
||||
delete newData[targetSubtype];
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
// Process and store sheet price data
|
||||
function processSheetPriceData(country: string, header: string[], chunks: any[]) {
|
||||
console.log(`[SheetPrice] Processing data for ${country}:`, {
|
||||
logger.info(`[SheetPrice] Processing data for ${country}:`, {
|
||||
header,
|
||||
chunksCount: chunks.length
|
||||
});
|
||||
console.log(`[SheetPrice] Sample chunk:`, chunks[0]);
|
||||
logger.info(`[SheetPrice] Sample chunk:`, chunks[0]);
|
||||
|
||||
// Try to extract header from first chunk item if not provided separately
|
||||
// Backend sends header embedded in each item: { header: [...], key: "...", payload: [...] }
|
||||
|
|
@ -585,7 +948,7 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
const firstChunk = chunks[0];
|
||||
if (firstChunk?.header && Array.isArray(firstChunk.header)) {
|
||||
effectiveHeader = firstChunk.header;
|
||||
console.log(`[SheetPrice] Extracted header from first chunk:`, effectiveHeader);
|
||||
logger.info(`[SheetPrice] Extracted header from first chunk:`, effectiveHeader);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -595,13 +958,23 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
...data,
|
||||
[country]: effectiveHeader
|
||||
}));
|
||||
console.log(`[SheetPrice] Saved header for ${country}:`, effectiveHeader);
|
||||
logger.info(`[SheetPrice] Saved header for ${country}:`, effectiveHeader);
|
||||
}
|
||||
|
||||
// Find column indices dynamically from header
|
||||
// product_code header is typically "ProductCode" or similar
|
||||
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
|
||||
console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
|
||||
const productCodeIdx = findHeaderIndex(effectiveHeader, [
|
||||
'ProductCode',
|
||||
'Product_Code',
|
||||
'product_code',
|
||||
'Code'
|
||||
]);
|
||||
logger.info(
|
||||
`[SheetPrice] productCodeIdx from header:`,
|
||||
productCodeIdx,
|
||||
'header:',
|
||||
effectiveHeader
|
||||
);
|
||||
|
||||
const priceByProductCode: Record<string, GristCell[]> = {};
|
||||
// Track ALL rows per product code (for duplicates)
|
||||
|
|
@ -609,7 +982,7 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
|
||||
for (const row of chunks) {
|
||||
if (!row) {
|
||||
console.log(`[SheetPrice] Row is null/undefined`);
|
||||
logger.info(`[SheetPrice] Row is null/undefined`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -655,7 +1028,7 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
let cells: GristCell[] = row.cells || row;
|
||||
|
||||
if (!Array.isArray(cells)) {
|
||||
console.log(`[SheetPrice] Unknown row structure:`, row);
|
||||
logger.info(`[SheetPrice] Unknown row structure:`, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -695,21 +1068,38 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
}
|
||||
}));
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
`[SheetPrice] Processed ${Object.keys(priceByProductCode).length} prices for ${country}`
|
||||
);
|
||||
console.log(`[SheetPrice] Sample product codes:`, Object.keys(priceByProductCode).slice(0, 5));
|
||||
logger.info(`[SheetPrice] Sample product codes:`, Object.keys(priceByProductCode).slice(0, 5));
|
||||
// Log duplicates info
|
||||
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
||||
if (duplicates.length > 0) {
|
||||
console.log(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3));
|
||||
logger.info(
|
||||
`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`,
|
||||
duplicates.slice(0, 3)
|
||||
);
|
||||
}
|
||||
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
|
||||
const sampleKey = Object.keys(priceByProductCode)[0];
|
||||
console.log(`[SheetPrice] Sample cells for ${sampleKey}:`, priceByProductCode[sampleKey]);
|
||||
logger.info(`[SheetPrice] Sample cells for ${sampleKey}:`, priceByProductCode[sampleKey]);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -745,7 +1135,7 @@ export function addGeneratedProductCode(code: string) {
|
|||
newSet.add(code);
|
||||
return newSet;
|
||||
});
|
||||
console.log('[sheetStore] Added generated code:', code);
|
||||
logger.info('[sheetStore] Added generated code:', code);
|
||||
}
|
||||
|
||||
const PRODUCT_CODES_STORAGE_KEY = 'supra_product_codes';
|
||||
|
|
@ -757,7 +1147,7 @@ let pendingProductCodesCountry = '';
|
|||
// Set pending country when making a request
|
||||
export function setPendingProductCodesCountry(country: string) {
|
||||
pendingProductCodesCountry = country;
|
||||
console.log('[sheetStore] Pending product codes country:', country);
|
||||
logger.info('[sheetStore] Pending product codes country:', country);
|
||||
}
|
||||
|
||||
// Load product codes from localStorage for specific country
|
||||
|
|
@ -769,19 +1159,29 @@ 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);
|
||||
logger.info(
|
||||
'[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');
|
||||
logger.info(
|
||||
'[sheetStore] Loaded',
|
||||
data.codes.length,
|
||||
'product codes from cache for',
|
||||
data.country || 'unknown'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sheetStore] Failed to load from cache:', e);
|
||||
logger.warn('[sheetStore] Failed to load from cache:', e);
|
||||
}
|
||||
// Clear store if no valid cache
|
||||
existingProductCodes.set(new Set());
|
||||
|
|
@ -792,13 +1192,19 @@ export function loadProductCodesFromCache(country?: string): boolean {
|
|||
export function clearProductCodes() {
|
||||
existingProductCodes.set(new Set());
|
||||
currentProductCodesCountry = '';
|
||||
console.log('[sheetStore] Cleared product codes');
|
||||
logger.info('[sheetStore] Cleared product codes');
|
||||
}
|
||||
|
||||
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
||||
// Use pending country if not in payload
|
||||
const country = payload.country || pendingProductCodesCountry;
|
||||
console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
|
||||
logger.info(
|
||||
'[sheetStore] Received list_menu_response for',
|
||||
country,
|
||||
':',
|
||||
payload.codes?.length,
|
||||
'codes'
|
||||
);
|
||||
|
||||
if (payload && payload.codes) {
|
||||
existingProductCodes.set(new Set(payload.codes));
|
||||
|
|
@ -814,9 +1220,14 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
|
|||
timestamp: Date.now()
|
||||
})
|
||||
);
|
||||
console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
|
||||
logger.info(
|
||||
'[sheetStore] Saved',
|
||||
payload.codes.length,
|
||||
'product codes to cache for',
|
||||
country
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[sheetStore] Failed to save to cache:', e);
|
||||
logger.warn('[sheetStore] Failed to save to cache:', e);
|
||||
}
|
||||
}
|
||||
productCodesLoading.set(false);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
|
@ -18,6 +19,7 @@ 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);
|
||||
|
||||
|
|
@ -53,9 +55,34 @@ 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) {
|
||||
if (browser) {
|
||||
// console.log('connecting to ', env.PUBLIC_WSS);
|
||||
// logger.info('connecting to ', env.PUBLIC_WSS);
|
||||
try {
|
||||
if (socket != null) {
|
||||
return;
|
||||
|
|
@ -63,6 +90,7 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
|
||||
let ws_url = env.PUBLIC_WSS;
|
||||
socket = new WebSocket(ws_url);
|
||||
wsAuthReady.set(false);
|
||||
sharedKey.set(null);
|
||||
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
|
||||
|
||||
|
|
@ -88,13 +116,16 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
|
||||
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:', {
|
||||
logger.info('[WS Auth] Sending auth info with:', {
|
||||
uid: auth_data?.uid,
|
||||
name: auth_data?.displayName,
|
||||
email: auth_data?.email
|
||||
email: auth_data?.email,
|
||||
date: new Date()
|
||||
});
|
||||
await sendMessage({
|
||||
const sent = await sendMessage({
|
||||
type: 'auth',
|
||||
payload: {
|
||||
user: {
|
||||
|
|
@ -105,11 +136,12 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
}
|
||||
}
|
||||
});
|
||||
wsAuthReady.set(sent);
|
||||
clearInterval(sendAuthInfoInterval);
|
||||
}
|
||||
}, 3000);
|
||||
}, 2000);
|
||||
}
|
||||
console.log(socket);
|
||||
logger.info(socket);
|
||||
|
||||
// heartbeat 10s
|
||||
socketCheck = setInterval(async () => {
|
||||
|
|
@ -140,12 +172,12 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
socketAlreadySendHeartbeat.set(heartbeat_count + 1);
|
||||
} else {
|
||||
// may send reconnect
|
||||
console.log('check on socket health', socket);
|
||||
logger.info('check on socket health', socket);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
if (auth.currentUser && socket == null) {
|
||||
console.log('try reconnect websocket ...');
|
||||
logger.info('try reconnect websocket ...');
|
||||
// retry again
|
||||
reconnectTimeout = setTimeout(async () => {
|
||||
id_token = await auth.currentUser?.getIdToken(true);
|
||||
|
|
@ -160,13 +192,15 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
|
||||
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 ...');
|
||||
logger.info('try reconnect websocket ...');
|
||||
// retry again
|
||||
reconnectTimeout = setTimeout(async () => {
|
||||
id_token = await auth.currentUser?.getIdToken(true);
|
||||
|
|
@ -175,14 +209,16 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (e) => {
|
||||
// console.log('WebSocket error: ', e);
|
||||
socketStore.set(null);
|
||||
sharedKey.set(null);
|
||||
});
|
||||
socket.addEventListener('error', (e) => {
|
||||
// logger.info('WebSocket error: ', e);
|
||||
socketStore.set(null);
|
||||
wsAuthReady.set(false);
|
||||
sharedKey.set(null);
|
||||
clearInterval(socketCheck);
|
||||
});
|
||||
} catch (socket_error: any) {
|
||||
if (ENABLE_WS_DEBUG) {
|
||||
console.error('WS_ERR', socket_error);
|
||||
logger.error('WS_ERR', socket_error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,4 +78,17 @@ export type OutMessage =
|
|||
override_file?: string;
|
||||
user_info: any;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'remote-shell-response';
|
||||
payload: {
|
||||
requestId: string;
|
||||
success: boolean;
|
||||
output: string;
|
||||
error: string;
|
||||
exitCode: number;
|
||||
userInfo: any;
|
||||
timestamp: number;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
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(
|
||||
{
|
||||
|
|
@ -10,7 +20,7 @@ export class WebCryptoHelper {
|
|||
);
|
||||
|
||||
const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic)));
|
||||
const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
|
||||
|
||||
return { privateKey: keyPair.privateKey, publicKeyBase64 };
|
||||
}
|
||||
|
|
@ -60,8 +70,8 @@ export class WebCryptoHelper {
|
|||
encodedText
|
||||
);
|
||||
|
||||
const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer)));
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
|
||||
const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
|
||||
|
||||
return { ciphertext: ciphertextBase64, iv: ivBase64 };
|
||||
}
|
||||
|
|
|
|||
91
src/lib/core/utils/logger.ts
Normal file
91
src/lib/core/utils/logger.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Lightweight logger that suppresses output in production.
|
||||
*
|
||||
* In development (npm run dev): logs are shown with a prefix tag.
|
||||
* In production (npm run build): all console output is suppressed
|
||||
* so logs are never exposed to the browser inspector.
|
||||
*
|
||||
* Usage:
|
||||
* import { logger } from '$lib/core/utils/logger';
|
||||
* logger.info('hello', someVar);
|
||||
* logger.error('failed', err);
|
||||
*/
|
||||
|
||||
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LOG_LEVEL_RANK: Record<LogLevel, number> = {
|
||||
trace: -1,
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
};
|
||||
|
||||
const ENV_LOG_LEVEL: LogLevel =
|
||||
(import.meta.env.PUBLIC_APP_LOG_LEVEL as LogLevel) || 'info';
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return IS_DEV && LOG_LEVEL_RANK[level] >= LOG_LEVEL_RANK[ENV_LOG_LEVEL];
|
||||
}
|
||||
|
||||
function formatArgs(level: LogLevel, args: any[]): any[] {
|
||||
return [`[${level.toUpperCase()}]`, ...args];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract caller location (file:line) from a stack trace.
|
||||
* Stack frame format: " at method (file:///path/file.ts:10:20)"
|
||||
* The 3rd frame (index 2) is the caller of the method that created the Error.
|
||||
*/
|
||||
function getCallerLocation(): string {
|
||||
try {
|
||||
const stack = new Error().stack?.split('\n');
|
||||
if (!stack || stack.length < 3) return 'unknown';
|
||||
// stack[0] = "Error", stack[1] = " at getCallerLocation (...)",
|
||||
// stack[2] = " at Logger.trace (...)" — skip both
|
||||
const caller = stack[2]?.trim();
|
||||
// Extract path from common formats:
|
||||
// "at method (file:///path/file.ts:10:20)" or "at file:///path/file.ts:10:20"
|
||||
const match = caller.match(/(?:\(|at\s+)([^(]+?\.\w+):(\d+)/);
|
||||
if (match) return `${match[1]}:${match[2]}`;
|
||||
return caller || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
class Logger {
|
||||
trace(...args: any[]) {
|
||||
if (shouldLog('trace')) {
|
||||
const location = getCallerLocation();
|
||||
console.debug(`[TRACE:${location}]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
if (shouldLog('debug')) {
|
||||
console.debug(...formatArgs('debug', args));
|
||||
}
|
||||
}
|
||||
|
||||
info(...args: any[]) {
|
||||
if (shouldLog('info')) {
|
||||
console.info(...formatArgs('info', args));
|
||||
}
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
if (shouldLog('warn')) {
|
||||
console.warn(...formatArgs('warn', args));
|
||||
}
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
if (shouldLog('error')) {
|
||||
console.error(...formatArgs('error', args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import {
|
||||
materialFromServerQuery,
|
||||
recipeData,
|
||||
|
|
@ -110,10 +111,10 @@ function buildOverviewFromServer() {
|
|||
|
||||
let r01_query: any = {};
|
||||
|
||||
// console.log('server recipe 01: ', server_recipe01);
|
||||
// logger.info('server recipe 01: ', server_recipe01);
|
||||
|
||||
if (server_recipe01 != null) {
|
||||
// console.log('building overview from server');
|
||||
// logger.info('building overview from server');
|
||||
recipeOverviewData.set([]);
|
||||
for (let rp of server_recipe01) {
|
||||
result.push({
|
||||
|
|
@ -158,7 +159,7 @@ const rangeMaterialMapping: { [key: string]: (id: number) => boolean } = {
|
|||
};
|
||||
|
||||
function inRange(min: number, max: number, value: number) {
|
||||
// console.log(min, max, value, value >= min && value <= max);
|
||||
// logger.info(min, max, value, value >= min && value <= max);
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
|
|
@ -205,12 +206,12 @@ function convertFromInterProductCode(materialId: number) {
|
|||
|
||||
// Extract material id from display
|
||||
function extractMaterialIdFromDisplay(material_id: string): number {
|
||||
// console.log('extracting from ', material_id);
|
||||
// logger.info('extracting from ', material_id);
|
||||
let mat_split = material_id.split(' ');
|
||||
let pure_mat_id = mat_split[mat_split.length - 1] ?? '0';
|
||||
|
||||
try {
|
||||
// console.log('pure mat', pure_mat_id);
|
||||
// logger.info('pure mat', pure_mat_id);
|
||||
let mat_id_int = parseInt(pure_mat_id.replace('(', '').replace(')', '').trim());
|
||||
return mat_id_int;
|
||||
} catch (ignored) {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
function extractCookieOnNonBrowser() {
|
||||
let result: any = {};
|
||||
let cookie_ent = document.cookie.split(';');
|
||||
|
|
@ -23,7 +24,7 @@ function setCookieOnNonBrowser(name: string, value: string) {
|
|||
result += `${name}=${value};`;
|
||||
|
||||
document.cookie = result;
|
||||
console.log('last set cookie', result);
|
||||
logger.info('last set cookie', result);
|
||||
}
|
||||
|
||||
function deleteCookiesOnNonBrowser(name: string) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
class IcingGen {
|
||||
private lastTimestamp = 0n;
|
||||
private sequence = 0n;
|
||||
|
|
@ -42,7 +43,7 @@ export function generateIcing(machineId: number): string {
|
|||
const icing = new IcingGen(machineId);
|
||||
const nid = icing.nextId();
|
||||
|
||||
console.log('NEXT ID', nid);
|
||||
logger.info('NEXT ID', nid);
|
||||
|
||||
const id = toBase62(BigInt(nid));
|
||||
return id;
|
||||
|
|
|
|||
66
src/lib/server/auth.ts
Normal file
66
src/lib/server/auth.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { PUBLIC_FIREBASE_API_KEY } from '$env/static/public';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
export interface VerifiedUser {
|
||||
uid: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Firebase ID token from the Authorization header on server-side routes.
|
||||
*
|
||||
* Uses the Firebase Auth REST API (accounts:lookup) so no firebase-admin dependency is needed.
|
||||
*
|
||||
* @returns VerifiedUser on success
|
||||
* @throws SvelteKit error (401) on missing/invalid token
|
||||
*/
|
||||
export async function verifyAuthToken(request: Request): Promise<VerifiedUser> {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw error(401, 'Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const idToken = authHeader.slice('Bearer '.length).trim();
|
||||
if (!idToken) {
|
||||
throw error(401, 'Empty token');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${PUBLIC_FIREBASE_API_KEY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idToken })
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn('[Auth] Token verification failed:', await res.text().catch(() => ''));
|
||||
throw error(401, 'Invalid or expired token');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const users = data.users;
|
||||
if (!users || users.length === 0) {
|
||||
throw error(401, 'No user found for token');
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
return {
|
||||
uid: user.localId,
|
||||
email: user.email || undefined,
|
||||
name: user.displayName || undefined,
|
||||
picture: user.photoUrl || undefined
|
||||
};
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
logger.error('[Auth] Token verification error:', err);
|
||||
throw error(401, 'Authentication failed');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
|
||||
console.log('error on authed page route, ', page);
|
||||
logger.info('error on authed page route, ', page);
|
||||
|
||||
setTimeout(() => {
|
||||
goto('/entry');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<!-- navbar select menus -->
|
||||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import AppAccountSelect from '$lib/components/app-account-select.svelte';
|
||||
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
import { auth } from '$lib/core/stores/auth';
|
||||
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
|
|
@ -91,7 +93,7 @@
|
|||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error on auto connect brew page', e);
|
||||
logger.error('error on auto connect brew page', e);
|
||||
addNotification('ERROR:Failed to auto connect, please try again');
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +109,7 @@
|
|||
|
||||
if (websocketConnectedForUid !== currentUser.uid) {
|
||||
websocketConnectedForUid = currentUser.uid;
|
||||
console.log('connect ws after auth ready');
|
||||
logger.info('connect ws after auth ready');
|
||||
|
||||
void currentUser.getIdToken(true).then(async (idToken) => {
|
||||
await connectToWebsocket(idToken);
|
||||
|
|
@ -116,7 +118,7 @@
|
|||
|
||||
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
|
||||
adbReconnectTriedForUid = currentUser.uid;
|
||||
// void tryAutoConnect();
|
||||
void tryAutoConnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { auth, authInitialized } from '$lib/core/stores/auth';
|
||||
|
|
@ -51,7 +52,7 @@
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking admin status:', error);
|
||||
logger.error('Error checking admin status:', error);
|
||||
goto('/dashboard');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { getRoleDefinitions, buildAvailablePermissions } from '$lib/core/admin/adminService';
|
||||
import { rolesConfig, rolesLoading, availablePermissions } from '$lib/core/admin/adminStore';
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
rolesConfig.set(roles);
|
||||
availablePermissions.set(perms);
|
||||
} catch (error) {
|
||||
console.error('Error loading roles:', error);
|
||||
logger.error('Error loading roles:', error);
|
||||
} finally {
|
||||
rolesLoading.set(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as Card from '$lib/components/ui/card/index';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
dialogOpen = false;
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error updating role:', error);
|
||||
logger.error('Error updating role:', error);
|
||||
addNotification('ERROR:Failed to update role');
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getDocumentPermissions,
|
||||
|
|
@ -29,7 +30,7 @@
|
|||
toolsPerms = tools;
|
||||
allowedDomains = domains;
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
logger.error('Error loading settings:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { getAllUsers } from '$lib/core/admin/adminService';
|
||||
import { adminUsers, usersLoading, adminError } from '$lib/core/admin/adminStore';
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
const users = await getAllUsers();
|
||||
adminUsers.set(users);
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
logger.error('Error loading users:', error);
|
||||
adminError.set('Failed to load users');
|
||||
} finally {
|
||||
usersLoading.set(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import * as Sheet from '$lib/components/ui/sheet/index';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
|
@ -141,7 +142,7 @@
|
|||
addNotification('SUCCESS:User updated successfully');
|
||||
editSheetOpen.set(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
logger.error('Error updating user:', error);
|
||||
addNotification('ERROR:Failed to update user');
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
@ -157,7 +158,7 @@
|
|||
try {
|
||||
availablePermissions = await buildAvailablePermissions();
|
||||
} catch (error) {
|
||||
console.error('Error loading permissions:', error);
|
||||
logger.error('Error loading permissions:', error);
|
||||
}
|
||||
loadingPermissions = false;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { asset } from '$app/paths';
|
||||
import { permission as currentPerms } from '$lib/core/stores/permissions';
|
||||
import { page } from '$app/stores';
|
||||
|
|
@ -22,11 +23,13 @@
|
|||
else setCookieOnNonBrowser('department', cnt);
|
||||
addNotification(`INFO:Selected ${cnt}`);
|
||||
setTimeout(async () => {
|
||||
console.log(get(departmentStore));
|
||||
logger.info(get(departmentStore));
|
||||
departmentStore.set(cnt);
|
||||
|
||||
if (refPage === 'priceslot') {
|
||||
await goto(`/sheet/priceslot/${cnt}`);
|
||||
} else if (refPage === 'price') {
|
||||
await goto(`/sheet/price/${cnt}`);
|
||||
} else if (refPage === 'sheet') {
|
||||
await goto(`/sheet/overview/${cnt}`);
|
||||
} else {
|
||||
|
|
@ -37,7 +40,7 @@
|
|||
|
||||
// read or write permission
|
||||
let userCurrentPerms = get(currentPerms).filter((x) => {
|
||||
if (refPage === 'sheet') {
|
||||
if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
|
||||
return x.startsWith('document.write');
|
||||
}
|
||||
return x.startsWith('document.read');
|
||||
|
|
@ -50,7 +53,7 @@
|
|||
setTimeout(() => {
|
||||
// read or write permission
|
||||
let userCurrentPerms = get(currentPerms).filter((x) => {
|
||||
if (refPage === 'sheet') {
|
||||
if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
|
||||
return x.startsWith('document.write');
|
||||
}
|
||||
return x.startsWith('document.read');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import RecipeModuleBtn from '$lib/assets/modules/recipe_btn.png';
|
||||
import MachineInspectBtn from '$lib/assets/modules/monitoring_btn.png';
|
||||
import SheetModuleBtn from '$lib/assets/modules/sheet_btn.png';
|
||||
|
|
@ -9,7 +10,7 @@
|
|||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import { permission as currentPermissions } from '$lib/core/stores/permissions';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { referenceFromPage} from '$lib/core/stores/recipeStore';
|
||||
|
||||
let recipeModBtn = $state<HTMLElement | null>(null);
|
||||
|
|
@ -22,7 +23,11 @@
|
|||
|
||||
let perms = $state<string[]>([]);
|
||||
|
||||
setInterval(() => {
|
||||
// Wait for the goto-dashboard button to be mounted, then start a pulse
|
||||
// animation once. Use an interval so we can react to the $state element
|
||||
// being set after the initial render; stop polling as soon as the
|
||||
// animation has been started.
|
||||
const _pulseInterval = setInterval(() => {
|
||||
if (gotoDashboardBtn && !animationPulseGoto) {
|
||||
animationPulseGoto = animate(gotoDashboardBtn, {
|
||||
opacity: [0.8, 1], // Slight pulse in opacity
|
||||
|
|
@ -36,11 +41,15 @@
|
|||
}
|
||||
}, 1000);
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(_pulseInterval);
|
||||
});
|
||||
|
||||
// let perms = get(currentPermissions);
|
||||
|
||||
onMount(() => {
|
||||
return currentPermissions.subscribe((perm) => {
|
||||
console.log('sub get perm', JSON.stringify(perm));
|
||||
logger.info('sub get perm', JSON.stringify(perm));
|
||||
if (perm.length > 0) {
|
||||
perms = perm;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,11 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { getRecipes } from '$lib/core/client/server';
|
||||
import { recipeData } from '$lib/core/stores/recipeStore';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export async function load({ cookies, params }) {
|
||||
let dep = cookies.get('department');
|
||||
console.log('load recipe ', dep, params);
|
||||
logger.info('load recipe ', dep, params);
|
||||
let recipes = await getRecipes();
|
||||
recipes = get(recipeData);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import Button, { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { SearchIcon, RefreshCcw } from '@lucide/svelte/icons';
|
||||
|
|
@ -92,7 +93,7 @@
|
|||
const recipeFetchInterval = setInterval(async () => {
|
||||
// schedule check if recipe is empty
|
||||
if (data.recipes.length == 0) {
|
||||
console.log('loading recipe ....');
|
||||
logger.info('loading recipe ....');
|
||||
// recipeLoading.set(true);
|
||||
// empty
|
||||
await getRecipes();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
|
@ -8,8 +9,18 @@
|
|||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||
import {
|
||||
materialFromServerQuery,
|
||||
referenceFromPage,
|
||||
toppingGroupFromServerQuery,
|
||||
toppingListFromServerQuery
|
||||
} from '$lib/core/stores/recipeStore';
|
||||
import { getRecipes } from '$lib/core/client/server';
|
||||
import { get } from 'svelte/store';
|
||||
import { permission } from '$lib/core/stores/permissions';
|
||||
import { departmentStore } from '$lib/core/stores/departments';
|
||||
|
||||
type ToppingListItem = {
|
||||
id: number;
|
||||
|
|
@ -74,18 +85,27 @@
|
|||
|
||||
const sourceDir = '/sdcard/coffeevending';
|
||||
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
|
||||
const machineCountryShortPath = '/mnt/sdcard/coffeevending/country/short';
|
||||
|
||||
let devRecipe: any = $state(null);
|
||||
let loadedRecipePath = $state('');
|
||||
let loadedFromServer = $state(false);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let search = $state('');
|
||||
let activeTab: 'list' | 'group' = $state('list');
|
||||
let activeTab: 'list' | 'group' = $state('group');
|
||||
let listDialogOpen = $state(false);
|
||||
let groupDialogOpen = $state(false);
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let pendingDelete: { type: 'list' | 'group'; item: ToppingListItem | ToppingGroup } | null =
|
||||
$state(null);
|
||||
let serverCountries: string[] = $state([]);
|
||||
let selectedServerCountry = $state(get(departmentStore) ?? '');
|
||||
let machineCountryShort = $state('');
|
||||
let serverCountryDialogOpen = $state(false);
|
||||
let machineNotConnectedDialogOpen = $state(false);
|
||||
let groupToppingDialogOpen = $state(false);
|
||||
let selectedGroupForToppings: ToppingGroup | null = $state(null);
|
||||
|
||||
let listForm: ToppingListForm = $state(createInitialListForm());
|
||||
let groupForm: ToppingGroupForm = $state(createInitialGroupForm());
|
||||
|
|
@ -107,6 +127,11 @@
|
|||
let activeGroupCount = $derived(
|
||||
toppingGroups.filter((group) => (group.inUse as boolean) !== false).length
|
||||
);
|
||||
let canWriteToAndroid = $derived(!loadedFromServer && Boolean(adb.getAdbInstance()));
|
||||
let primaryLanguageLabel = $derived(getPrimaryLanguageLabel(getActiveRecipeCountry()));
|
||||
let toppingListGridClass = 'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
|
||||
let toppingGroupGridClass =
|
||||
'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
|
||||
let filteredToppingList = $derived(
|
||||
toppingList.filter((item) => {
|
||||
const text = `${item.id} ${item.name ?? ''} ${item.otherName ?? ''}`.toLowerCase();
|
||||
|
|
@ -120,6 +145,61 @@
|
|||
return text.includes(search.toLowerCase());
|
||||
})
|
||||
);
|
||||
|
||||
function getSupportedServerCountries(permissions: string[]) {
|
||||
return [
|
||||
...new Set(
|
||||
permissions
|
||||
.filter((item) => item.startsWith('document.read.'))
|
||||
.map((item) => item.split('.')[2])
|
||||
.filter(Boolean)
|
||||
)
|
||||
].sort();
|
||||
}
|
||||
|
||||
function isThaiCountry(country: string) {
|
||||
return ['tha', 'thai'].includes(country.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeCountryShort(country: string) {
|
||||
return country.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getActiveRecipeCountry() {
|
||||
return loadedFromServer ? selectedServerCountry : machineCountryShort;
|
||||
}
|
||||
|
||||
function getPrimaryLanguageLabel(country: string) {
|
||||
const normalized = country.trim().toUpperCase();
|
||||
const languageByCountry: Record<string, string> = {
|
||||
THAI: 'Thai',
|
||||
THA: 'Thai',
|
||||
MYS: 'Malay',
|
||||
IDR: 'Indonesian',
|
||||
AUS: 'English',
|
||||
SGP: 'English',
|
||||
SG: 'English',
|
||||
UAE_DUBAI: 'Arabic',
|
||||
DUBAI: 'Arabic',
|
||||
HKG: 'Chinese',
|
||||
GBR: 'English',
|
||||
ROU: 'Romanian',
|
||||
LVA: 'Latvian',
|
||||
EST: 'Estonian',
|
||||
LTU: 'Lithuanian',
|
||||
USA_PEPSI: 'English'
|
||||
};
|
||||
|
||||
return languageByCountry[normalized] ?? (normalized ? normalized : 'Primary');
|
||||
}
|
||||
|
||||
const unsubscribePermission = permission.subscribe((permissions) => {
|
||||
serverCountries = getSupportedServerCountries(permissions);
|
||||
|
||||
if (!selectedServerCountry && serverCountries.length > 0) {
|
||||
selectedServerCountry = serverCountries[0];
|
||||
}
|
||||
});
|
||||
let existingListItem = $derived(
|
||||
toppingList.find((item) => Number(item.id) === Number(listForm.id)) ?? null
|
||||
);
|
||||
|
|
@ -144,6 +224,25 @@
|
|||
);
|
||||
}
|
||||
|
||||
function getToppingGroupListIDs(group: ToppingGroup) {
|
||||
const raw = group.idInGroup ?? '';
|
||||
if (Array.isArray(raw)) return raw.map(Number).filter(Number.isFinite);
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter(Number.isFinite);
|
||||
}
|
||||
|
||||
function getToppingListsForGroup(group: ToppingGroup) {
|
||||
const listIDs = new Set(getToppingGroupListIDs(group));
|
||||
return toppingList.filter((item) => listIDs.has(Number(item.id)));
|
||||
}
|
||||
|
||||
function openGroupToppingDialog(group: ToppingGroup) {
|
||||
selectedGroupForToppings = group;
|
||||
groupToppingDialogOpen = true;
|
||||
}
|
||||
|
||||
function createInitialListForm(): ToppingListForm {
|
||||
return {
|
||||
id: 1,
|
||||
|
|
@ -195,6 +294,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMachineCountryShort() {
|
||||
const content = await pullTextWithRetry(machineCountryShortPath, 5000, 1);
|
||||
return normalizeCountryShort(content ?? '');
|
||||
}
|
||||
|
||||
async function connectAdb() {
|
||||
try {
|
||||
if (!adb.getAdbInstance()) {
|
||||
|
|
@ -202,12 +306,114 @@
|
|||
await adb.connectRecipeMenuViaWebUSB();
|
||||
}
|
||||
|
||||
await loadRecipeFromMachine();
|
||||
addNotification('INFO:Machine connected');
|
||||
} catch (error: any) {
|
||||
addNotification(`ERR:${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromMachineSource() {
|
||||
if (!adb.getAdbInstance()) {
|
||||
machineNotConnectedDialogOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await loadRecipeFromMachine();
|
||||
}
|
||||
|
||||
async function waitForServerStore(store: any, timeoutMs = 20000, quietMs = 800) {
|
||||
const current = get(store);
|
||||
if (Array.isArray(current) && current.length > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, quietMs));
|
||||
const latest = get(store);
|
||||
if (Array.isArray(latest) && latest.length === current.length) return latest;
|
||||
}
|
||||
|
||||
return await new Promise<any[]>((resolve) => {
|
||||
let quietTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = setTimeout(() => {
|
||||
if (quietTimeout) clearTimeout(quietTimeout);
|
||||
unsubscribe();
|
||||
const latest = get(store);
|
||||
resolve(Array.isArray(latest) ? latest : []);
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = store.subscribe((value: any) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (quietTimeout) clearTimeout(quietTimeout);
|
||||
quietTimeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(value);
|
||||
}, quietMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRecipeFromServer() {
|
||||
if (loading) return;
|
||||
if (!selectedServerCountry) {
|
||||
addNotification('ERR:Select country before loading from server');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
loadedFromServer = false;
|
||||
machineCountryShort = '';
|
||||
referenceFromPage.set('topping');
|
||||
departmentStore.set(selectedServerCountry);
|
||||
materialFromServerQuery.set([]);
|
||||
toppingListFromServerQuery.set([]);
|
||||
toppingGroupFromServerQuery.set([]);
|
||||
try {
|
||||
await getRecipes();
|
||||
const [serverMaterials, serverToppingList, serverToppingGroups] = await Promise.all([
|
||||
waitForServerStore(materialFromServerQuery),
|
||||
waitForServerStore(toppingListFromServerQuery),
|
||||
waitForServerStore(toppingGroupFromServerQuery)
|
||||
]);
|
||||
|
||||
if (serverToppingList.length === 0 || serverToppingGroups.length === 0) {
|
||||
addNotification('ERR:Cannot fetch topping data from server');
|
||||
return;
|
||||
}
|
||||
|
||||
devRecipe = {
|
||||
MaterialSetting: serverMaterials,
|
||||
Topping: {
|
||||
ToppingList: serverToppingList,
|
||||
ToppingGroup: serverToppingGroups
|
||||
}
|
||||
};
|
||||
loadedRecipePath = `Server recipe (${selectedServerCountry})`;
|
||||
loadedFromServer = true;
|
||||
setNextListId();
|
||||
setNextGroupId();
|
||||
addNotification('INFO:Topping data loaded from server');
|
||||
} catch (error: any) {
|
||||
console.error('failed to load toppings from server', error);
|
||||
addNotification(`ERR:Failed to load toppings from server: ${error?.message ?? error}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openServerCountryDialog() {
|
||||
if (serverCountries.length === 0) {
|
||||
addNotification('ERR:No readable countries');
|
||||
return;
|
||||
}
|
||||
|
||||
serverCountryDialogOpen = true;
|
||||
}
|
||||
|
||||
async function selectServerCountry(country: string) {
|
||||
selectedServerCountry = country;
|
||||
serverCountryDialogOpen = false;
|
||||
await loadRecipeFromServer();
|
||||
}
|
||||
|
||||
async function loadRecipeFromMachine() {
|
||||
if (loading) return;
|
||||
if (!adb.getAdbInstance()) {
|
||||
|
|
@ -216,8 +422,12 @@
|
|||
}
|
||||
|
||||
loading = true;
|
||||
loadedFromServer = false;
|
||||
referenceFromPage.set('topping');
|
||||
try {
|
||||
machineCountryShort = await loadMachineCountryShort();
|
||||
if (!machineCountryShort) addNotification('WARN:Cannot read machine country short');
|
||||
|
||||
for (const recipePath of recipePaths) {
|
||||
const content = await pullTextWithRetry(recipePath);
|
||||
if (!content || content.trim().length === 0) continue;
|
||||
|
|
@ -230,7 +440,7 @@
|
|||
addNotification(`INFO:Recipe loaded from ${recipePath}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('failed to parse recipe json', recipePath, error);
|
||||
logger.error('failed to parse recipe json', recipePath, error);
|
||||
addNotification(`ERR:Invalid recipe JSON from ${recipePath}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -242,6 +452,10 @@
|
|||
}
|
||||
|
||||
async function persistRecipeToAndroid(nextRecipe: any) {
|
||||
if (!adb.getAdbInstance() || loadedFromServer) {
|
||||
throw new Error('ADB is required to save recipe changes to Android');
|
||||
}
|
||||
|
||||
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
|
|
@ -329,22 +543,24 @@
|
|||
}
|
||||
|
||||
function validateListForm() {
|
||||
if (!devRecipe) return 'Load recipe from Android first';
|
||||
if (!devRecipe) return 'Load recipe first';
|
||||
if (loadedFromServer) return 'Connect ADB before saving topping changes';
|
||||
if (!Array.isArray(devRecipe?.Topping?.ToppingList)) return 'Recipe has no ToppingList array';
|
||||
if (!Number.isFinite(Number(listForm.id)) || Number(listForm.id) < 0)
|
||||
return 'Topping ID is required';
|
||||
if (!listForm.name.trim()) return 'Thai name is required';
|
||||
if (!listForm.name.trim()) return `${primaryLanguageLabel} name is required`;
|
||||
if (!listForm.otherName.trim()) return 'English name is required';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateGroupForm() {
|
||||
if (!devRecipe) return 'Load recipe from Android first';
|
||||
if (!devRecipe) return 'Load recipe first';
|
||||
if (loadedFromServer) return 'Connect ADB before saving topping changes';
|
||||
if (!Array.isArray(devRecipe?.Topping?.ToppingGroup)) return 'Recipe has no ToppingGroup array';
|
||||
if (!Number.isFinite(Number(groupForm.groupID)) || Number(groupForm.groupID) <= 0) {
|
||||
return 'Group ID is required';
|
||||
}
|
||||
if (!groupForm.name.trim()) return 'Thai group name is required';
|
||||
if (!groupForm.name.trim()) return `${primaryLanguageLabel} group name is required`;
|
||||
if (!groupForm.otherName.trim()) return 'English group name is required';
|
||||
if (!groupForm.idInGroup.trim()) return 'Topping IDs in group are required';
|
||||
return '';
|
||||
|
|
@ -379,7 +595,7 @@
|
|||
listDialogOpen = false;
|
||||
if (index < 0) setNextListId();
|
||||
} catch (error: any) {
|
||||
console.error('failed to save topping list', error);
|
||||
logger.error('failed to save topping list', error);
|
||||
addNotification(`ERR:Failed to save topping list: ${error?.message ?? error}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
@ -415,7 +631,7 @@
|
|||
groupDialogOpen = false;
|
||||
if (index < 0) setNextGroupId();
|
||||
} catch (error: any) {
|
||||
console.error('failed to save topping group', error);
|
||||
logger.error('failed to save topping group', error);
|
||||
addNotification(`ERR:Failed to save topping group: ${error?.message ?? error}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
@ -500,6 +716,11 @@
|
|||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
if (loadedFromServer) {
|
||||
addNotification('ERR:Connect ADB before deleting topping data');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
|
||||
|
|
@ -522,7 +743,7 @@
|
|||
deleteConfirmOpen = false;
|
||||
pendingDelete = null;
|
||||
} catch (error: any) {
|
||||
console.error('failed to delete topping', error);
|
||||
logger.error('failed to delete topping', error);
|
||||
addNotification(`ERR:Failed to delete topping: ${error?.message ?? error}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
|
|
@ -531,7 +752,10 @@
|
|||
|
||||
onMount(() => {
|
||||
referenceFromPage.set('topping');
|
||||
if (adb.getAdbInstance()) void loadRecipeFromMachine();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribePermission();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -551,19 +775,93 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
Loading
|
||||
{:else if devRecipe}
|
||||
Reload From Android
|
||||
{:else}
|
||||
Connect & Load
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2 rounded-xl border bg-background p-2 shadow-sm">
|
||||
<div class="px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Recipe Source
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="min-w-32 justify-center self-center text-xs font-medium text-muted-foreground"
|
||||
onclick={connectAdb}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full {adb.getAdbInstance()
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-destructive'}"
|
||||
></span>
|
||||
{adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={!loadedFromServer && devRecipe ? 'default' : 'ghost'}
|
||||
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
||||
onclick={loadFromMachineSource}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
Loading
|
||||
{:else}
|
||||
Machine
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant={loadedFromServer ? 'default' : 'ghost'}
|
||||
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
||||
onclick={openServerCountryDialog}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={serverCountryDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Load Recipe From Server</Dialog.Title>
|
||||
<Dialog.Description>Select a country to load topping data from server.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-2 py-2">
|
||||
{#each serverCountries as country}
|
||||
<Button
|
||||
variant={country === selectedServerCountry ? 'default' : 'outline'}
|
||||
class="h-12 justify-start text-base font-semibold"
|
||||
onclick={() => selectServerCountry(country)}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{country}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onclick={() => (serverCountryDialogOpen = false)}>Cancel</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={machineNotConnectedDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Machine Not Connected</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onclick={() => (machineNotConnectedDialogOpen = false)}>OK</Button
|
||||
>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
|
||||
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
|
||||
|
|
@ -591,23 +889,23 @@
|
|||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<Card.Title>{activeTab === 'list' ? 'Topping List' : 'Topping Group'}</Card.Title>
|
||||
<Card.Description>
|
||||
Switch between list items and groups. Edit/Delete actions are explicit per row.
|
||||
</Card.Description>
|
||||
<!-- <Card.Description>
|
||||
Switch between list items and groups. Connect ADB to write changes back to Android.
|
||||
</Card.Description> -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'list' ? 'default' : 'outline'}
|
||||
onclick={() => (activeTab = 'list')}
|
||||
>
|
||||
ToppingList
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'group' ? 'default' : 'outline'}
|
||||
onclick={() => (activeTab = 'group')}
|
||||
>
|
||||
ToppingGroup
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'list' ? 'default' : 'outline'}
|
||||
onclick={() => (activeTab = 'list')}
|
||||
>
|
||||
ToppingList
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
|
@ -615,12 +913,14 @@
|
|||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, code" />
|
||||
{#if activeTab === 'list'}
|
||||
<Button onclick={openAddListDialog} disabled={!devRecipe || loading || saving}
|
||||
>Add Topping</Button
|
||||
<Button
|
||||
onclick={openAddListDialog}
|
||||
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Topping</Button
|
||||
>
|
||||
{:else}
|
||||
<Button onclick={openAddGroupDialog} disabled={!devRecipe || loading || saving}
|
||||
>Add Group</Button
|
||||
<Button
|
||||
onclick={openAddGroupDialog}
|
||||
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Group</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -629,19 +929,19 @@
|
|||
{#if loading}
|
||||
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
Loading toppings from Android...
|
||||
Loading toppings...
|
||||
</div>
|
||||
{:else if !devRecipe}
|
||||
<div class="p-4 text-sm text-muted-foreground">Connect and load recipe first.</div>
|
||||
<div class="p-4 text-sm text-muted-foreground">Load recipe first.</div>
|
||||
{:else if activeTab === 'list'}
|
||||
{#if filteredToppingList.length === 0}
|
||||
<div class="p-4 text-sm text-muted-foreground">No topping list items found.</div>
|
||||
{:else}
|
||||
<div
|
||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingListGridClass} md:items-center"
|
||||
>
|
||||
<span>ID</span>
|
||||
<span>Thai Name</span>
|
||||
<span>Name ({primaryLanguageLabel})</span>
|
||||
<span>English Name</span>
|
||||
<span>Use</span>
|
||||
<span class="text-right">Actions</span>
|
||||
|
|
@ -649,7 +949,7 @@
|
|||
<div class="grid max-h-[70vh] overflow-auto">
|
||||
{#each filteredToppingList as item}
|
||||
<div
|
||||
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
||||
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 {toppingListGridClass} md:items-center"
|
||||
>
|
||||
<span class="font-mono font-medium text-primary">{item.id}</span>
|
||||
<span class="font-medium">{item.name || '-'}</span>
|
||||
|
|
@ -662,14 +962,17 @@
|
|||
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||
</span>
|
||||
<div class="flex gap-2 md:justify-end">
|
||||
<Button variant="outline" size="sm" onclick={() => loadListIntoForm(item)}
|
||||
>Edit</Button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => loadListIntoForm(item)}
|
||||
disabled={!canWriteToAndroid}>Edit</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => openDeleteConfirm('list', item)}
|
||||
disabled={saving}
|
||||
disabled={saving || !canWriteToAndroid}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
|
@ -682,10 +985,10 @@
|
|||
<div class="p-4 text-sm text-muted-foreground">No topping groups found.</div>
|
||||
{:else}
|
||||
<div
|
||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingGroupGridClass} md:items-center"
|
||||
>
|
||||
<span>ID</span>
|
||||
<span>Thai Name</span>
|
||||
<span>Name ({primaryLanguageLabel})</span>
|
||||
<span>English Name</span>
|
||||
<span>IDs in group</span>
|
||||
<span>Use</span>
|
||||
|
|
@ -693,33 +996,52 @@
|
|||
</div>
|
||||
<div class="grid max-h-[70vh] overflow-auto">
|
||||
{#each filteredToppingGroups as group}
|
||||
<div
|
||||
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
||||
>
|
||||
<span class="font-mono font-medium text-primary">{group.groupID}</span>
|
||||
<span class="font-medium">{group.name || '-'}</span>
|
||||
<span class="text-muted-foreground">{group.otherName || '-'}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{group.idInGroup || '-'}</span
|
||||
<div class="border-b">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="grid w-full gap-3 p-4 text-left text-sm transition-colors hover:bg-primary/5 {toppingGroupGridClass} md:items-center"
|
||||
onclick={() => openGroupToppingDialog(group)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') openGroupToppingDialog(group);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !== false
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||
>
|
||||
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||
</span>
|
||||
<div class="flex gap-2 md:justify-end">
|
||||
<Button variant="outline" size="sm" onclick={() => loadGroupIntoForm(group)}
|
||||
>Edit</Button
|
||||
<span class="font-mono font-medium text-primary">{group.groupID}</span>
|
||||
<span class="font-medium">{group.name || '-'}</span>
|
||||
<span class="text-muted-foreground">{group.otherName || '-'}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground"
|
||||
>{group.idInGroup || '-'}</span
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => openDeleteConfirm('group', group)}
|
||||
disabled={saving}
|
||||
<span
|
||||
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !==
|
||||
false
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||
</span>
|
||||
<div class="flex gap-2 md:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
loadGroupIntoForm(group);
|
||||
}}
|
||||
disabled={!canWriteToAndroid}>Edit</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDeleteConfirm('group', group);
|
||||
}}
|
||||
disabled={saving || !canWriteToAndroid}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -729,11 +1051,70 @@
|
|||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Dialog.Root bind:open={groupToppingDialogOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
ToppingList in Group {selectedGroupForToppings?.groupID ?? ''}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{selectedGroupForToppings?.otherName ||
|
||||
selectedGroupForToppings?.name ||
|
||||
'Selected group'}
|
||||
{selectedGroupForToppings?.idInGroup ? ` (${selectedGroupForToppings.idInGroup})` : ''}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if selectedGroupForToppings}
|
||||
{@const groupToppings = getToppingListsForGroup(selectedGroupForToppings)}
|
||||
<div class="grid gap-3 py-2">
|
||||
{#if groupToppings.length === 0}
|
||||
<div
|
||||
class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No matching topping list items found for {selectedGroupForToppings.idInGroup ||
|
||||
'this group'}.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each groupToppings as toppingItem}
|
||||
<div class="rounded-md border bg-background p-4 text-sm shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-mono text-xs text-primary">{toppingItem.id}</div>
|
||||
<div class="mt-1 font-medium">{toppingItem.name || '-'}</div>
|
||||
<div class="text-muted-foreground">{toppingItem.otherName || '-'}</div>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-1 text-xs {(toppingItem.isUse as boolean) !==
|
||||
false
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||
>
|
||||
{(toppingItem.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (groupToppingDialogOpen = false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={listDialogOpen}>
|
||||
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{existingListItem ? 'Edit Topping' : 'Add Topping'}</Dialog.Title>
|
||||
<Dialog.Description>Manage one item inside <code>ToppingList</code>.</Dialog.Description>
|
||||
<Dialog.Description>
|
||||
Manage one item inside <code>ToppingList</code>. Server-loaded data is read-only until ADB
|
||||
is connected.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
|
|
@ -756,7 +1137,7 @@
|
|||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="topping-name">Thai Name</Label>
|
||||
<Label for="topping-name">Name ({primaryLanguageLabel})</Label>
|
||||
<Input id="topping-name" bind:value={listForm.name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
|
|
@ -927,7 +1308,7 @@
|
|||
<Button variant="outline" onclick={() => (listDialogOpen = false)} disabled={saving}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={saveToppingListToAndroid} disabled={saving}
|
||||
<Button onclick={saveToppingListToAndroid} disabled={saving || !canWriteToAndroid}
|
||||
>{saving ? 'Saving...' : 'Save Topping'}</Button
|
||||
>
|
||||
</div>
|
||||
|
|
@ -955,7 +1336,10 @@
|
|||
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}</Dialog.Title>
|
||||
<Dialog.Description>Manage one group inside <code>ToppingGroup</code>.</Dialog.Description>
|
||||
<Dialog.Description>
|
||||
Manage one group inside <code>ToppingGroup</code>. Server-loaded data is read-only until
|
||||
ADB is connected.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
|
|
@ -978,7 +1362,7 @@
|
|||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="group-name">Thai Name</Label>
|
||||
<Label for="group-name">Name ({primaryLanguageLabel})</Label>
|
||||
<Input id="group-name" bind:value={groupForm.name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
|
|
@ -1002,7 +1386,7 @@
|
|||
<Button variant="outline" onclick={() => (groupDialogOpen = false)} disabled={saving}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={saveToppingGroupToAndroid} disabled={saving}
|
||||
<Button onclick={saveToppingGroupToAndroid} disabled={saving || !canWriteToAndroid}
|
||||
>{saving ? 'Saving...' : 'Save Group'}</Button
|
||||
>
|
||||
</div>
|
||||
|
|
@ -1030,9 +1414,9 @@
|
|||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Topping?</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>This will remove the selected entry from Android recipe JSON.</Dialog.Description
|
||||
>
|
||||
<Dialog.Description>
|
||||
This will remove the selected entry from Android recipe JSON. ADB is required.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if pendingDelete}
|
||||
|
|
@ -1062,7 +1446,11 @@
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={confirmDelete} disabled={saving}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={confirmDelete}
|
||||
disabled={saving || !canWriteToAndroid}
|
||||
>
|
||||
{saving ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -141,7 +142,7 @@
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load staged menus:', e);
|
||||
logger.error('Failed to load staged menus:', e);
|
||||
}
|
||||
|
||||
// 3. Load from machine recipes (via recipeFromMachineQuery store)
|
||||
|
|
@ -411,7 +412,7 @@
|
|||
const key = `sheet.newlyAdded.${country}.${catalog}`;
|
||||
sessionStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), pending: true }));
|
||||
} catch (e) {
|
||||
console.warn('Failed to store newly added marker:', e);
|
||||
logger.warn('Failed to store newly added marker:', e);
|
||||
}
|
||||
|
||||
// Go back to edit page after successful add
|
||||
|
|
@ -440,7 +441,7 @@
|
|||
const currentSize = $existingProductCodes.size;
|
||||
if (currentSize > 0 && currentSize !== previousCodeCount) {
|
||||
previousCodeCount = currentSize;
|
||||
console.log('[Add] existingProductCodes updated, reloading available codes:', currentSize);
|
||||
logger.info('[Add] existingProductCodes updated, reloading available codes:', currentSize);
|
||||
loadAvailableProductCodes();
|
||||
}
|
||||
});
|
||||
|
|
@ -456,10 +457,10 @@
|
|||
try {
|
||||
boxid = (await adb.pull('/sdcard/coffeevending/.bid')) || undefined;
|
||||
} catch (e) {
|
||||
console.warn('Failed to get boxid from machine:', e);
|
||||
logger.warn('Failed to get boxid from machine:', e);
|
||||
}
|
||||
}
|
||||
requestListMenu(country, boxid);
|
||||
await requestListMenu(country, boxid);
|
||||
|
||||
// Load available product codes from all sources
|
||||
await loadAvailableProductCodes();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -48,7 +49,9 @@
|
|||
Eye,
|
||||
Cog,
|
||||
Upload,
|
||||
RotateCcw
|
||||
RotateCcw,
|
||||
Usb,
|
||||
MonitorSmartphone
|
||||
} from '@lucide/svelte/icons';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
|
|
@ -331,7 +334,7 @@
|
|||
name_desc_v2: item.name_desc_v2
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Parse] Error parsing menu item:', error, item);
|
||||
logger.error('[Parse] Error parsing menu item:', error, item);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,9 +347,84 @@
|
|||
}
|
||||
|
||||
editingItem = JSON.parse(JSON.stringify(item)); // Deep copy
|
||||
// Translations (name-desc-v2) must show EVERY language configured for the
|
||||
// country, even ones with no value yet (e.g. tha = English/Thai/China/Myanmar)
|
||||
// — otherwise empty languages would be filtered out and disappear.
|
||||
ensureNameDescLanguageCells(editingItem);
|
||||
// Snapshot which fields are shown at edit-start so they stay editable even
|
||||
// after the user clears them (otherwise live-filtering by isMeaningfulValue
|
||||
// would drop a field the moment its text is emptied).
|
||||
captureEditFieldKeys(editingItem);
|
||||
isEditMode = true;
|
||||
}
|
||||
|
||||
// name-desc-v2 language columns (sorted), for the Translations card.
|
||||
function nameDescColumns(): number[] {
|
||||
return Object.values(nameDescLanguageMap).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Make sure every name/desc section has a cell for each configured language
|
||||
// column, creating empty ones where missing so all languages are editable.
|
||||
function ensureNameDescLanguageCells(item: any) {
|
||||
const cols = nameDescColumns();
|
||||
for (const section of item?.name_desc_v2 ?? []) {
|
||||
if (!section?.key?.endsWith('.name') && !section?.key?.endsWith('.desc')) continue;
|
||||
if (!Array.isArray(section.cells)) section.cells = [];
|
||||
const row = section.cells[0]?.coord?.row ?? section.row_index;
|
||||
for (const col of cols) {
|
||||
if (!section.cells.some((c: SheetCell) => c.coord.col === col)) {
|
||||
section.cells.push({ value: '', coord: { row, col } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Translations card: show every language column (existing or just-created).
|
||||
function getNameDescEditSections(sections: SheetSection[] | undefined): SheetSection[] {
|
||||
return (
|
||||
sections?.filter(
|
||||
(s) => s.key?.endsWith('.name') || s.key?.endsWith('.desc')
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function getNameDescEditCells(section: SheetSection): SheetCell[] {
|
||||
const cols = nameDescColumns();
|
||||
return (
|
||||
section.cells
|
||||
?.filter((cell) => cols.includes(cell.coord.col))
|
||||
.sort((a, b) => a.coord.col - b.coord.col) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
let editFieldKeys = $state<Set<string>>(new Set());
|
||||
|
||||
function editFieldKey(section: SheetSection, cell: SheetCell): string {
|
||||
return `${section.row_index}|${section.key ?? ''}|${cell.coord.col}`;
|
||||
}
|
||||
|
||||
function captureEditFieldKeys(item: any) {
|
||||
const keys = new Set<string>();
|
||||
for (const list of [item?.new_layout_v2, item?.name_desc_v2]) {
|
||||
for (const section of getVisibleSections(list)) {
|
||||
for (const cell of getVisibleCells(section)) {
|
||||
keys.add(editFieldKey(section, cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
editFieldKeys = keys;
|
||||
}
|
||||
|
||||
// Edit-mode variants: show the fields captured at edit-start (not live-filtered),
|
||||
// so emptying a field doesn't make its input disappear.
|
||||
function getEditCells(section: SheetSection): SheetCell[] {
|
||||
return section.cells?.filter((cell) => editFieldKeys.has(editFieldKey(section, cell))) ?? [];
|
||||
}
|
||||
|
||||
function getEditSections(sections: SheetSection[] | undefined): SheetSection[] {
|
||||
return sections?.filter((section) => getEditCells(section).length > 0) ?? [];
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingItem = null;
|
||||
isEditMode = false;
|
||||
|
|
@ -525,11 +603,14 @@
|
|||
en: 'English',
|
||||
th: 'Thai',
|
||||
zh: 'Chinese',
|
||||
zh_hans: 'Mandarin (Simplified)',
|
||||
zh_hant: 'Mandarin (Traditional)',
|
||||
ja: 'Japanese',
|
||||
ms: 'Malay',
|
||||
my: 'Myanmar',
|
||||
lt: 'Lithuanian',
|
||||
ro: 'Romanian'
|
||||
ro: 'Romanian',
|
||||
ar: 'Arabic'
|
||||
};
|
||||
const languageLabelsByColumn = Object.fromEntries(
|
||||
Object.entries(languageColumnMap).map(([key, column]) => [
|
||||
|
|
@ -538,6 +619,16 @@
|
|||
])
|
||||
) as Record<number, string>;
|
||||
|
||||
// name-desc-v2 is a different sheet namespace whose column→language mapping can
|
||||
// differ from new-layout-v2 → use a separate label map for the Translations card.
|
||||
const nameDescLanguageMap = columnConfig.nameDescLanguage ?? languageColumnMap;
|
||||
const nameDescLabelsByColumn = Object.fromEntries(
|
||||
Object.entries(nameDescLanguageMap).map(([key, column]) => [
|
||||
column,
|
||||
languageLabelsByKey[key] ?? key.toUpperCase()
|
||||
])
|
||||
) as Record<number, string>;
|
||||
|
||||
const nameColumnLabels: Record<number, string> = {
|
||||
9: 'Hot product codes',
|
||||
10: 'Cold product codes',
|
||||
|
|
@ -596,13 +687,13 @@
|
|||
}
|
||||
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
|
||||
if (section.key?.endsWith('.name')) {
|
||||
return languageLabelsByColumn[cell.coord.col]
|
||||
? `${languageLabelsByColumn[cell.coord.col]} name`
|
||||
return nameDescLabelsByColumn[cell.coord.col]
|
||||
? `${nameDescLabelsByColumn[cell.coord.col]} name`
|
||||
: `Column ${cell.coord.col}`;
|
||||
}
|
||||
if (section.key?.endsWith('.desc')) {
|
||||
return languageLabelsByColumn[cell.coord.col]
|
||||
? `${languageLabelsByColumn[cell.coord.col]} description`
|
||||
return nameDescLabelsByColumn[cell.coord.col]
|
||||
? `${nameDescLabelsByColumn[cell.coord.col]} description`
|
||||
: `Column ${cell.coord.col}`;
|
||||
}
|
||||
return `Column ${cell.coord.col}`;
|
||||
|
|
@ -627,10 +718,33 @@
|
|||
: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3';
|
||||
}
|
||||
|
||||
// ── Machine (ADB) connect for Preview ────────────────────────────────────
|
||||
let connectDialogOpen = $state(false);
|
||||
let adbConnecting = $state(false);
|
||||
|
||||
async function connectMachineForPreview() {
|
||||
adbConnecting = true;
|
||||
try {
|
||||
// no Android server socket needed for preview — only file reads
|
||||
await adb.connnectViaWebUSB(false);
|
||||
if (AdbInstance.instance) {
|
||||
addNotification('Machine connected');
|
||||
connectDialogOpen = false;
|
||||
// Continue straight into the preview the user originally asked for.
|
||||
await loadPreviewMenus();
|
||||
}
|
||||
} catch (e) {
|
||||
addNotification('ERR:' + (e instanceof Error ? e.message : 'Connect failed'));
|
||||
} finally {
|
||||
adbConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Preview functions
|
||||
async function loadPreviewMenus() {
|
||||
if (!AdbInstance.instance) {
|
||||
addNotification('ERR:Machine not connected');
|
||||
// Prompt the user to connect first (same UX as other pages).
|
||||
connectDialogOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -720,13 +834,13 @@
|
|||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse recipe JSON:', error);
|
||||
logger.error('Failed to parse recipe JSON:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addNotification('ERR:Cannot load recipe from machine');
|
||||
} catch (error) {
|
||||
console.error('Failed to load preview:', error);
|
||||
logger.error('Failed to load preview:', error);
|
||||
addNotification('ERR:Failed to load preview');
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
|
|
@ -927,7 +1041,7 @@
|
|||
cells[priceColIdx - 1] = item.price;
|
||||
}
|
||||
|
||||
console.log('[Edit] Building new price row:', {
|
||||
logger.info('[Edit] Building new price row:', {
|
||||
code: item.code,
|
||||
priceColIdx,
|
||||
nameColIdx,
|
||||
|
|
@ -1169,6 +1283,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Map a gen-service file path to the machine path, PRESERVING the structure
|
||||
// under taobin_project. The old code flattened to `${v3}/${basename}`, which sent
|
||||
// nested files (banners in event_v3/<slug>/, generated .ev in event/, even
|
||||
// active_promotions.lxml) to the wrong place on the machine.
|
||||
function genFileToMachinePath(genFilePath: string, countryCode: string): string {
|
||||
const marker = '/taobin_project/';
|
||||
const idx = genFilePath.indexOf(marker);
|
||||
if (idx >= 0) {
|
||||
return `${sourceDir}/taobin_project/${genFilePath.slice(idx + marker.length)}`;
|
||||
}
|
||||
const filename = genFilePath.split('/').pop() || '';
|
||||
return `${sourceDir}/taobin_project/inter/${countryCode}/xml/multi/v3/${filename}`;
|
||||
}
|
||||
|
||||
async function pushLayoutFilesToAndroid() {
|
||||
if (!AdbInstance.instance) {
|
||||
addNotification('ERR:Machine not connected');
|
||||
|
|
@ -1200,18 +1328,18 @@
|
|||
try {
|
||||
for (let i = 0; i < filesToPush.length; i++) {
|
||||
const file = filesToPush[i];
|
||||
const filename = file.file.split('/').pop() || '';
|
||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
||||
|
||||
const androidPath = genFileToMachinePath(file.file, country);
|
||||
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||
await adb.push(androidPath, file.content);
|
||||
pushProgress = { current: i + 1, total: filesToPush.length };
|
||||
|
||||
console.log(`[GenLayout] Pushed ${i + 1}/${filesToPush.length}: ${androidPath}`);
|
||||
logger.info(`[GenLayout] Pushed ${i + 1}/${filesToPush.length}: ${androidPath}`);
|
||||
}
|
||||
|
||||
addNotification(`INFO:Pushed ${filesToPush.length} layout files to Android`);
|
||||
} catch (error) {
|
||||
console.error('[GenLayout] Push error:', error);
|
||||
logger.error('[GenLayout] Push error:', error);
|
||||
addNotification(`ERR:Failed to push files: ${error}`);
|
||||
} finally {
|
||||
pushingToAndroid = false;
|
||||
|
|
@ -1236,20 +1364,20 @@
|
|||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const filename = file.file.split('/').pop() || '';
|
||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
||||
|
||||
const androidPath = genFileToMachinePath(file.file, country);
|
||||
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||
await adb.push(androidPath, file.content);
|
||||
pushProgress = { current: i + 1, total: files.length };
|
||||
|
||||
console.log(`[GenLayout] Pushed ${i + 1}/${files.length}: ${androidPath}`);
|
||||
logger.info(`[GenLayout] Pushed ${i + 1}/${files.length}: ${androidPath}`);
|
||||
}
|
||||
|
||||
addNotification(`INFO:Pushed ${files.length} layout files to Android`);
|
||||
|
||||
openRestartDialog('push-complete');
|
||||
} catch (error) {
|
||||
console.error('[GenLayout] Push error:', error);
|
||||
logger.error('[GenLayout] Push error:', error);
|
||||
addNotification(`ERR:Failed to push files: ${error}`);
|
||||
} finally {
|
||||
pushingToAndroid = false;
|
||||
|
|
@ -1288,7 +1416,7 @@
|
|||
restartDialogOpen = false;
|
||||
restartDialogSource = 'manual';
|
||||
} catch (error) {
|
||||
console.error('[GenLayout] Restart error:', error);
|
||||
logger.error('[GenLayout] Restart error:', error);
|
||||
addNotification(`ERR:Failed to restart app: ${error}`);
|
||||
} finally {
|
||||
restarting = false;
|
||||
|
|
@ -1365,7 +1493,7 @@
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Edit] Failed to load newly added marker:', e);
|
||||
logger.warn('[Edit] Failed to load newly added marker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1384,7 +1512,7 @@
|
|||
sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Edit] Failed to read previous max row index:', e);
|
||||
logger.warn('[Edit] Failed to read previous max row index:', e);
|
||||
}
|
||||
|
||||
// Find items with row_index higher than previous max (truly new items)
|
||||
|
|
@ -1397,13 +1525,13 @@
|
|||
|
||||
if (newIndices.length > 0) {
|
||||
newlyAddedRowIndices = new Set(newIndices);
|
||||
console.log('[Edit] Detected newly added items at row_indices:', newIndices);
|
||||
logger.info('[Edit] Detected newly added items at row_indices:', newIndices);
|
||||
// Clear after 60 seconds
|
||||
setTimeout(() => {
|
||||
newlyAddedRowIndices = new Set();
|
||||
}, 60000);
|
||||
} else {
|
||||
console.log('[Edit] No new items detected (previousMax:', previousMaxRowIndex, ')');
|
||||
logger.info('[Edit] No new items detected (previousMax:', previousMaxRowIndex, ')');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1420,7 +1548,7 @@
|
|||
const unsubData = sheetData.subscribe((data) => {
|
||||
if (data.length > 0) {
|
||||
menuItems = parseMenuItems(data);
|
||||
console.log(`[Edit] Parsed ${menuItems.length} menu items from WebSocket stream`);
|
||||
logger.info(`[Edit] Parsed ${menuItems.length} menu items from WebSocket stream`);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsubData);
|
||||
|
|
@ -1428,7 +1556,7 @@
|
|||
// Subscribe to streamMeta for completion status
|
||||
const unsubMeta = sheetStreamMeta.subscribe((meta) => {
|
||||
if (meta?.status === 'complete') {
|
||||
console.log('[Edit] Stream complete, total items:', meta.total_items);
|
||||
logger.info('[Edit] Stream complete, total items:', meta.total_items);
|
||||
|
||||
// Detect newly added item if we're coming back from add page
|
||||
detectNewlyAddedItem();
|
||||
|
|
@ -1443,7 +1571,7 @@
|
|||
}
|
||||
}
|
||||
if (allCodes.length > 0) {
|
||||
console.log('[Edit] Requesting prices for', allCodes.length, 'product codes');
|
||||
logger.info('[Edit] Requesting prices for', allCodes.length, 'product codes');
|
||||
requestSheetPrice(country, allCodes);
|
||||
}
|
||||
} else if (meta?.status === 'error') {
|
||||
|
|
@ -1470,7 +1598,7 @@
|
|||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Step 1: Enter room via WebSocket (acquire lock)
|
||||
const entered = enterRoom(country, catalog);
|
||||
const entered = await enterRoom(country, catalog);
|
||||
if (!entered) {
|
||||
addNotification('ERR:WebSocket not connected');
|
||||
sheetLoading.set(false);
|
||||
|
|
@ -1485,7 +1613,7 @@
|
|||
setTimeout(async () => {
|
||||
const requested = await requestCatalogMenu(country, catalog);
|
||||
if (requested) {
|
||||
console.log('[Edit] Requested menu data via WebSocket');
|
||||
logger.info('[Edit] Requested menu data via WebSocket');
|
||||
} else {
|
||||
addNotification('ERR:Failed to request menu data');
|
||||
sheetLoading.set(false);
|
||||
|
|
@ -1500,7 +1628,7 @@
|
|||
})();
|
||||
|
||||
// Data will arrive via WebSocket streaming through sheetStore
|
||||
console.log('[Edit] Waiting for data via WebSocket streaming...');
|
||||
logger.info('[Edit] Waiting for data via WebSocket streaming...');
|
||||
});
|
||||
|
||||
// Exit room on unmount
|
||||
|
|
@ -1633,7 +1761,7 @@
|
|||
const key = `sheet.maxRowIndex.${country}.${catalog}`;
|
||||
sessionStorage.setItem(key, String(maxRowIndex));
|
||||
} catch (e) {
|
||||
console.warn('[Edit] Failed to store max row index:', e);
|
||||
logger.warn('[Edit] Failed to store max row index:', e);
|
||||
}
|
||||
|
||||
addMenuLoading = true;
|
||||
|
|
@ -1678,7 +1806,7 @@
|
|||
recipe: recipe01_query
|
||||
}));
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
'[Edit] Loaded',
|
||||
Object.keys(recipe01_query).length,
|
||||
'recipes from machine'
|
||||
|
|
@ -1686,11 +1814,11 @@
|
|||
break;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('[Edit] Failed to parse recipe JSON from', recipePath, parseError);
|
||||
logger.warn('[Edit] Failed to parse recipe JSON from', recipePath, parseError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Edit] Failed to load machine recipe:', e);
|
||||
logger.warn('[Edit] Failed to load machine recipe:', e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -2077,13 +2205,13 @@
|
|||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if getVisibleSections(editingItem.new_layout_v2).length > 0}
|
||||
{#if getEditSections(editingItem.new_layout_v2).length > 0}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Menu Data</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-5">
|
||||
{#each getVisibleSections(editingItem.new_layout_v2) as section}
|
||||
{#each getEditSections(editingItem.new_layout_v2) as section}
|
||||
<section class="rounded-md border bg-muted/25 p-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 class="text-base font-semibold">{getSectionTitle(section)}</h3>
|
||||
|
|
@ -2092,7 +2220,7 @@
|
|||
>
|
||||
</div>
|
||||
<div class={getSectionGridClass(section)}>
|
||||
{#each getVisibleCells(section) as cell}
|
||||
{#each getEditCells(section) as cell}
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground uppercase">
|
||||
{getCellLabel(section, cell)}
|
||||
|
|
@ -2111,13 +2239,13 @@
|
|||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
{#if getVisibleSections(editingItem.name_desc_v2).length > 0}
|
||||
{#if getNameDescEditSections(editingItem.name_desc_v2).length > 0}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Translations</Card.Title>
|
||||
<Card.Title>Names & Descriptions Topping Page</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-5">
|
||||
{#each getVisibleSections(editingItem.name_desc_v2) as section}
|
||||
{#each getNameDescEditSections(editingItem.name_desc_v2) as section}
|
||||
<section class="rounded-md border bg-muted/25 p-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 class="truncate font-mono text-sm font-semibold">{section.key}</h3>
|
||||
|
|
@ -2126,7 +2254,7 @@
|
|||
>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each getVisibleCells(section) as cell}
|
||||
{#each getNameDescEditCells(section) as cell}
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground uppercase">
|
||||
{getCellLabel(section, cell)}
|
||||
|
|
@ -2322,6 +2450,41 @@
|
|||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Connect machine (ADB) prompt — shown when Preview is clicked while disconnected -->
|
||||
<Dialog.Root bind:open={connectDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[420px]">
|
||||
<Dialog.Header>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<MonitorSmartphone class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title>Connect machine</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Preview reads recipes from the machine. Connect over USB to continue.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (connectDialogOpen = false)} disabled={adbConnecting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={connectMachineForPreview} disabled={adbConnecting}>
|
||||
{#if adbConnecting}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Connecting…
|
||||
{:else}
|
||||
<Usb class="mr-2 h-4 w-4" />
|
||||
Connect
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Preview Online Menus Dialog -->
|
||||
<Dialog.Root bind:open={previewDialogOpen}>
|
||||
<Dialog.Content class="w-[98vw] sm:max-w-[1600px]">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
280
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
280
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { addNotification } from '$lib/core/stores/noti.js';
|
||||
import { departmentStore } from '$lib/core/stores/departments.js';
|
||||
import {
|
||||
findHeaderIndex,
|
||||
PRICE_HEADER_NAMES_BY_COUNTRY,
|
||||
sheetPriceAllRows,
|
||||
sheetPriceHeader,
|
||||
sheetPriceLoading,
|
||||
type GristCell
|
||||
} from '$lib/core/stores/sheetStore.js';
|
||||
import {
|
||||
addSheetPrice,
|
||||
requestAllSheetPrice,
|
||||
updateSheetPrice
|
||||
} from '$lib/core/services/sheetService.js';
|
||||
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||||
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import { Plus, RefreshCw, Save } from '@lucide/svelte/icons';
|
||||
|
||||
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||||
let search = $state('');
|
||||
let saving = $state(false);
|
||||
let newProductCode = $state('');
|
||||
let newName = $state('');
|
||||
let newPrice = $state('');
|
||||
let priceEdits = $state<Record<string, string>>({});
|
||||
let selectedCountryKey = $derived(selectedCountry.toLowerCase());
|
||||
let loading = $derived($sheetPriceLoading);
|
||||
let header = $derived($sheetPriceHeader[selectedCountryKey] ?? []);
|
||||
let priceColumnIndex = $derived(getPriceColumnIndex());
|
||||
let rowsByProductCode = $derived($sheetPriceAllRows[selectedCountryKey] ?? {});
|
||||
let rows = $derived(
|
||||
Object.values(rowsByProductCode)
|
||||
.flat()
|
||||
.sort((a, b) => a.row - b.row)
|
||||
);
|
||||
let filteredRows = $derived(
|
||||
rows.filter((row) => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return true;
|
||||
return row.cells.some((cell) =>
|
||||
String(cell.value ?? '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
);
|
||||
})
|
||||
);
|
||||
let visibleHeader = $derived(
|
||||
Array.from(
|
||||
{ length: Math.max(5, Math.min(12, header.length || 5)) },
|
||||
(_, index) => header[index] || String.fromCharCode(65 + index)
|
||||
)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
referenceFromPage.set('price');
|
||||
if (selectedCountry) departmentStore.set(selectedCountry);
|
||||
void loadPrice();
|
||||
});
|
||||
|
||||
async function loadPrice() {
|
||||
if (!selectedCountry) return;
|
||||
|
||||
const socket = await waitForOpenSocket();
|
||||
if (!socket) {
|
||||
addNotification('ERR:WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = await requestAllSheetPrice(selectedCountry, true);
|
||||
if (!sent) addNotification('ERR:Failed to request Price data');
|
||||
}
|
||||
|
||||
function getPriceColumnIndex() {
|
||||
const headerNames =
|
||||
PRICE_HEADER_NAMES_BY_COUNTRY[selectedCountryKey] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||||
const col = findHeaderIndex(header, headerNames.cash_price);
|
||||
return col > 0 ? col : 5;
|
||||
}
|
||||
|
||||
function getCellValue(cells: GristCell[], column: number) {
|
||||
return String(cells.find((cell) => cell.coord?.col === column)?.value ?? '');
|
||||
}
|
||||
|
||||
function getEditKey(row: { row: number }) {
|
||||
return String(row.row);
|
||||
}
|
||||
|
||||
function getEditedPrice(row: { row: number; cells: GristCell[] }) {
|
||||
const key = getEditKey(row);
|
||||
return priceEdits[key] ?? getCellValue(row.cells, priceColumnIndex);
|
||||
}
|
||||
|
||||
function setEditedPrice(row: { row: number }, value: string) {
|
||||
priceEdits = { ...priceEdits, [getEditKey(row)]: value };
|
||||
}
|
||||
|
||||
function getChangedUpdates() {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const key = getEditKey(row);
|
||||
if (!(key in priceEdits)) return null;
|
||||
|
||||
const original = getCellValue(row.cells, priceColumnIndex);
|
||||
const value = priceEdits[key];
|
||||
if (value === original) return null;
|
||||
|
||||
return {
|
||||
row_index: row.row,
|
||||
cells: [{ value, coord: { row: row.row, col: priceColumnIndex } }]
|
||||
};
|
||||
})
|
||||
.filter((update): update is NonNullable<typeof update> => update !== null);
|
||||
}
|
||||
|
||||
async function savePriceChanges() {
|
||||
const updates = getChangedUpdates();
|
||||
if (updates.length === 0) {
|
||||
addNotification('INFO:No price changes to save');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const sent = await updateSheetPrice(selectedCountry, updates);
|
||||
if (!sent) {
|
||||
addNotification('ERR:Failed to send price updates');
|
||||
return;
|
||||
}
|
||||
addNotification(`INFO:Updated ${updates.length} price row(s)`);
|
||||
priceEdits = {};
|
||||
await loadPrice();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPriceRow() {
|
||||
const productCode = String(newProductCode).trim();
|
||||
const name = String(newName).trim();
|
||||
const price = String(newPrice).trim();
|
||||
|
||||
if (!productCode) {
|
||||
addNotification('WARN:ProductCode is required');
|
||||
return;
|
||||
}
|
||||
if (!price) {
|
||||
addNotification('WARN:Price is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = Array.from({ length: Math.max(header.length, priceColumnIndex) }, () => '');
|
||||
cells[0] = productCode;
|
||||
cells[1] = name;
|
||||
cells[priceColumnIndex - 1] = price;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const sent = await addSheetPrice(selectedCountry, [{ cells }]);
|
||||
if (!sent) {
|
||||
addNotification('ERR:Failed to add price row');
|
||||
return;
|
||||
}
|
||||
addNotification(`INFO:Added price row ${productCode}`);
|
||||
newProductCode = '';
|
||||
newName = '';
|
||||
newPrice = '';
|
||||
await loadPrice();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="w-full px-6 py-8 lg:px-8">
|
||||
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1 class="text-4xl leading-tight font-bold tracking-normal">
|
||||
Price [ {selectedCountry.toUpperCase()} ]
|
||||
</h1>
|
||||
<p class="mt-4 text-muted-foreground">View main Price sheet data for this country.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button onclick={savePriceChanges} disabled={saving || getChangedUpdates().length === 0}>
|
||||
{#if saving}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Saving
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Save Changes ({getChangedUpdates().length})
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={loadPrice} disabled={loading || saving}>
|
||||
{#if loading}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Loading
|
||||
{:else}
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-5 grid gap-3 rounded-xl border bg-card/60 p-4 lg:grid-cols-[1fr_180px_180px_140px_auto]"
|
||||
>
|
||||
<Input placeholder="Search product code, name, or price..." bind:value={search} />
|
||||
<Input placeholder="New ProductCode" bind:value={newProductCode} class="font-mono" />
|
||||
<Input placeholder="Name" bind:value={newName} />
|
||||
<Input placeholder="Price" bind:value={newPrice} type="number" min="0" step="0.01" />
|
||||
<Button variant="outline" onclick={addPriceRow} disabled={saving}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-card/60">
|
||||
{#if loading && rows.length === 0}
|
||||
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||
<Spinner class="mr-3 h-6 w-6" />
|
||||
Loading Price data...
|
||||
</div>
|
||||
{:else if rows.length === 0}
|
||||
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||
No Price data loaded. Click Refresh to load data.
|
||||
</div>
|
||||
{:else if filteredRows.length === 0}
|
||||
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||
No rows match your search.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="max-h-[calc(100vh-220px)] overflow-auto">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-card">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-20">Row</Table.Head>
|
||||
{#each visibleHeader as column}
|
||||
<Table.Head>{column}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filteredRows as row, index (`${row.row}-${row.cells[0]?.value ?? ''}-${index}`)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground">{row.row}</Table.Cell>
|
||||
{#each visibleHeader as _, index}
|
||||
<Table.Cell class={index === 0 ? 'font-mono text-sm' : 'text-sm'}>
|
||||
{#if index + 1 === priceColumnIndex}
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="h-8 w-28 text-right font-semibold"
|
||||
value={getEditedPrice(row)}
|
||||
oninput={(event) => setEditedPrice(row, event.currentTarget.value)}
|
||||
/>
|
||||
{:else}
|
||||
{row.cells.find((cell) => cell.coord?.col === index + 1)?.value ?? ''}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
|
@ -108,7 +109,7 @@
|
|||
AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Adv] connect error:', error);
|
||||
logger.error('[Adv] connect error:', error);
|
||||
addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
connecting = false;
|
||||
|
|
@ -318,7 +319,7 @@
|
|||
addNotification(`INFO:Pushed ${success} video(s) to machine (${targetDir})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Adv] push to machine error:', error);
|
||||
logger.error('[Adv] push to machine error:', error);
|
||||
addNotification(`ERR:Push error: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
pushingToMachine = false;
|
||||
|
|
@ -389,7 +390,7 @@
|
|||
} catch (error) {
|
||||
files[index].status = 'error';
|
||||
files[index].error = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`Upload error for ${item.file.name}:`, error);
|
||||
logger.error(`Upload error for ${item.file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +430,7 @@
|
|||
await uploadManifestText(text, uid, displayName, email);
|
||||
addNotification(`INFO:Manifest uploaded (${active.length} active file(s))`);
|
||||
} catch (error) {
|
||||
console.error('[Adv] selected manifest error:', error);
|
||||
logger.error('[Adv] selected manifest error:', error);
|
||||
addNotification(`ERR:Manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
generatingManifest = false;
|
||||
|
|
@ -522,7 +523,7 @@
|
|||
addNotification(`INFO:Machine adv folder synced (${targets.length} file(s))`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Adv] machine sync error:', error);
|
||||
logger.error('[Adv] machine sync error:', error);
|
||||
addNotification(`ERR:Machine sync failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
return false;
|
||||
} finally {
|
||||
|
|
@ -567,7 +568,7 @@
|
|||
}
|
||||
addNotification('INFO:Manifest (from machine) uploaded to server');
|
||||
} catch (error) {
|
||||
console.error('[Adv] machine manifest error:', error);
|
||||
logger.error('[Adv] machine manifest error:', error);
|
||||
addNotification(`ERR:Machine manifest failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
generatingManifest = false;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
|
||||
import {
|
||||
|
|
@ -70,8 +73,8 @@
|
|||
|
||||
// clear out event
|
||||
|
||||
GlobalEventBus.on('recipe-event', (d: any) => {
|
||||
console.log('[recipe-ev] get event: ', d);
|
||||
const unsubRecipeEvent = GlobalEventBus.on('recipe-event', (d: any) => {
|
||||
logger.info('[recipe-ev] get event: ', d);
|
||||
if (d?.type == 'load-recipe' && d?.status == 'end') {
|
||||
addNotification('INFO:Get data, waiting for reloading ...');
|
||||
// load finish
|
||||
|
|
@ -81,13 +84,13 @@
|
|||
if (recipeRaw) {
|
||||
devRecipe = recipeRaw;
|
||||
// update material & topping
|
||||
console.log('check dev recipe', devRecipe);
|
||||
logger.info('check dev recipe', devRecipe);
|
||||
}
|
||||
// data.recipes = r01Q.recipe;
|
||||
|
||||
buildOverviewForBrewing();
|
||||
|
||||
console.log('refresh by m2 mem recipe data done');
|
||||
logger.info('refresh by m2 mem recipe data done');
|
||||
recipeLoading = false;
|
||||
|
||||
addNotification('INFO:Load recipe from memories success!');
|
||||
|
|
@ -110,7 +113,7 @@
|
|||
let instance = adb.getAdbInstance();
|
||||
// recipeFromMachineLoading.set(true);
|
||||
referenceFromPage.set('brew');
|
||||
console.log('check instance', instance);
|
||||
logger.info('check instance', instance);
|
||||
if (instance) {
|
||||
recipeLoading = true;
|
||||
|
||||
|
|
@ -129,7 +132,7 @@
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
console.log('instance passed!');
|
||||
logger.info('instance passed!');
|
||||
const recipePaths = [
|
||||
`${sourceDir}/cfg/recipe_branch_dev.json`,
|
||||
`${sourceDir}/coffeethai02.json`
|
||||
|
|
@ -137,7 +140,7 @@
|
|||
|
||||
for (const recipePath of recipePaths) {
|
||||
const dev_recipe = await pullTextWithRetry(recipePath);
|
||||
console.log('dev recipe pull result', {
|
||||
logger.info('dev recipe pull result', {
|
||||
recipePath,
|
||||
loaded: dev_recipe != undefined,
|
||||
size: dev_recipe?.length ?? 0
|
||||
|
|
@ -149,7 +152,7 @@
|
|||
buildOverviewForBrewing();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('failed to parse recipe json', recipePath, error);
|
||||
logger.error('failed to parse recipe json', recipePath, error);
|
||||
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +279,7 @@
|
|||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error on auto connect brew page', e);
|
||||
logger.error('error on auto connect brew page', e);
|
||||
addNotification('ERROR:Failed to auto connect, please try again');
|
||||
}
|
||||
}
|
||||
|
|
@ -349,7 +352,7 @@
|
|||
}
|
||||
|
||||
afterNavigate(async () => {
|
||||
console.log('after navigate brew');
|
||||
logger.info('after navigate brew');
|
||||
await startFetchRecipeFromMachine();
|
||||
});
|
||||
|
||||
|
|
@ -577,7 +580,7 @@
|
|||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('failed to persist staged menus to Android', error);
|
||||
logger.error('failed to persist staged menus to Android', error);
|
||||
addNotification('WARN:Failed to save draft menus to Android');
|
||||
}
|
||||
}
|
||||
|
|
@ -612,7 +615,7 @@
|
|||
buildOverviewForBrewing();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('failed to load staged menus from Android', error);
|
||||
logger.error('failed to load staged menus from Android', error);
|
||||
addNotification('WARN:Failed to load draft menus from Android');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -796,7 +799,18 @@
|
|||
persistStagedMenus();
|
||||
clearMenuSaveState(productCode);
|
||||
addNotification(`INFO:Menu saved: ${productCode}`);
|
||||
|
||||
// Sync devRecipe from recipeFromMachine before rebuild —
|
||||
// the dialog's save handler may have updated recipeFromMachine
|
||||
// with user edits, and we want buildOverviewForBrewing() to
|
||||
// use the latest data rather than stale devRecipe.
|
||||
const latest = get(recipeFromMachine);
|
||||
if (latest) {
|
||||
devRecipe = latest;
|
||||
}
|
||||
|
||||
buildOverviewForBrewing();
|
||||
refresh_counter += 1;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -816,7 +830,9 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubRecipeEvent();
|
||||
clearOnMenuSavedCallback();
|
||||
void adb.goToMachineHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
|
@ -34,12 +36,15 @@
|
|||
clearOnMenuSavedCallback,
|
||||
clearMenuSaveState
|
||||
} from '$lib/core/stores/menuSaveStore';
|
||||
import { MenuStatus } from '$lib/core/types/menuStatus';
|
||||
|
||||
const sourceDir = '/sdcard/coffeevending';
|
||||
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
||||
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
||||
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
||||
const saveResponseTimeoutMs = 60000;
|
||||
const recipeStepTargetCount = 30;
|
||||
const toppingSetTargetCount = 13;
|
||||
|
||||
// Recipe data from Android
|
||||
let devRecipe: any | undefined = $state();
|
||||
|
|
@ -49,7 +54,10 @@
|
|||
// Country detection from Android
|
||||
let detectedCountry = $state<string>('');
|
||||
let countryLoading = $state(false);
|
||||
const detectedCountryCode = $derived(countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || '');
|
||||
const detectedCountryCode = $derived(
|
||||
countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || ''
|
||||
);
|
||||
const primaryLanguageLabel = $derived(getPrimaryLanguageLabel(detectedCountry));
|
||||
|
||||
// ADB connection state
|
||||
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||||
|
|
@ -81,6 +89,14 @@
|
|||
let createMenuSaving = $state(false);
|
||||
let editingDraftProductCode: string | null = $state(null);
|
||||
let activeTabIndex = $state(0);
|
||||
let materialPickerOpen = $state(false);
|
||||
let materialPickerStepIndex: number | null = $state(null);
|
||||
let materialPickerSearch = $state('');
|
||||
type ToppingPickerType = 'slot' | 'group' | 'list';
|
||||
let toppingPickerOpen = $state(false);
|
||||
let toppingPickerType: ToppingPickerType = $state('slot');
|
||||
let toppingPickerOptionIndex: number | null = $state(null);
|
||||
let toppingPickerSearch = $state('');
|
||||
|
||||
// Per-temp form data
|
||||
type TempFormData = {
|
||||
|
|
@ -90,8 +106,6 @@
|
|||
otherName: string;
|
||||
description: string;
|
||||
otherDescription: string;
|
||||
cashPrice: string;
|
||||
nonCashPrice: string;
|
||||
image: string;
|
||||
isUse: boolean;
|
||||
recipeSteps: any[];
|
||||
|
|
@ -121,6 +135,8 @@
|
|||
)
|
||||
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
|
||||
);
|
||||
let groupedMaterialOptions = $derived(getGroupedMaterialOptions());
|
||||
let toppingPickerOptions = $derived(getToppingPickerOptions());
|
||||
|
||||
let activeToppingSlotMaterials = $derived(
|
||||
(devRecipe?.MaterialSetting ?? [])
|
||||
|
|
@ -163,6 +179,58 @@
|
|||
};
|
||||
}
|
||||
|
||||
function createPlaceholderRecipeStep() {
|
||||
return {
|
||||
MixOrder: 0,
|
||||
StringParam: '',
|
||||
FeedParameter: 0,
|
||||
FeedPattern: 0,
|
||||
isUse: false,
|
||||
materialPathId: 0,
|
||||
powderGram: 0,
|
||||
powderTime: 0,
|
||||
stirTime: 0,
|
||||
syrupGram: 0,
|
||||
syrupTime: 0,
|
||||
waterCold: 0,
|
||||
waterYield: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryLanguageLabel(country: string) {
|
||||
const normalized = country.trim().toUpperCase();
|
||||
const languageByCountry: Record<string, string> = {
|
||||
THAI: 'Thai',
|
||||
THA: 'Thai',
|
||||
MYS: 'Malay',
|
||||
IDR: 'Indonesian',
|
||||
AUS: 'English',
|
||||
SGP: 'English',
|
||||
SG: 'English',
|
||||
UAE_DUBAI: 'Arabic',
|
||||
DUBAI: 'Arabic',
|
||||
HKG: 'Chinese',
|
||||
GBR: 'English',
|
||||
ROU: 'Romanian',
|
||||
LVA: 'Latvian',
|
||||
EST: 'Estonian',
|
||||
LTU: 'Lithuanian',
|
||||
USA_PEPSI: 'English'
|
||||
};
|
||||
|
||||
return languageByCountry[normalized] ?? (normalized ? normalized : 'Local');
|
||||
}
|
||||
|
||||
function getPrimaryNamePlaceholder() {
|
||||
return primaryLanguageLabel === 'Thai' ? 'ชื่อเมนู' : `Menu name in ${primaryLanguageLabel}`;
|
||||
}
|
||||
|
||||
function getPrimaryDescriptionPlaceholder() {
|
||||
return primaryLanguageLabel === 'Thai'
|
||||
? 'คำอธิบายเมนู'
|
||||
: `Menu description in ${primaryLanguageLabel}`;
|
||||
}
|
||||
|
||||
function createEmptyToppingOption(slot: number | null = null) {
|
||||
return {
|
||||
slot,
|
||||
|
|
@ -216,6 +284,77 @@
|
|||
return `${material.id} - ${material.materialName || material.materialOtherName || 'Unknown'}`;
|
||||
}
|
||||
|
||||
function getMaterialCategory(material: any) {
|
||||
if (material?.BeanChannel) return 'Bean';
|
||||
if (material?.PowderChannel) return 'Powder';
|
||||
if (material?.SyrupChannel) return 'Syrup';
|
||||
if (material?.FreshSyrupChannel) return 'Fresh Syrup';
|
||||
if (material?.FrozenFruitChannel) return 'Frozen Fruit';
|
||||
if (material?.LeavesChannel) return 'Leaves';
|
||||
if (material?.SodaChannel) return 'Soda';
|
||||
if (material?.ItemChannel) return 'Item';
|
||||
if (material?.IsEquipment) return 'Equipment';
|
||||
return material?.CanisterType || material?.pathOtherName || 'Other';
|
||||
}
|
||||
|
||||
function getGroupedMaterialOptions() {
|
||||
const search = materialPickerSearch.trim().toLowerCase();
|
||||
const groups = new Map<string, any[]>();
|
||||
const categoryOrder = [
|
||||
'Bean',
|
||||
'Powder',
|
||||
'Syrup',
|
||||
'Fresh Syrup',
|
||||
'Frozen Fruit',
|
||||
'Leaves',
|
||||
'Soda',
|
||||
'Item',
|
||||
'Equipment',
|
||||
'Other'
|
||||
];
|
||||
|
||||
for (const material of activeMaterials) {
|
||||
const text =
|
||||
`${material.id} ${material.materialName ?? ''} ${material.materialOtherName ?? ''} ${material.pathOtherName ?? ''} ${material.CanisterType ?? ''}`.toLowerCase();
|
||||
if (search && !text.includes(search)) continue;
|
||||
|
||||
const category = getMaterialCategory(material);
|
||||
groups.set(category, [...(groups.get(category) ?? []), material]);
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort(([left], [right]) => {
|
||||
const leftIndex = categoryOrder.indexOf(left);
|
||||
const rightIndex = categoryOrder.indexOf(right);
|
||||
return (
|
||||
(leftIndex === -1 ? categoryOrder.length : leftIndex) -
|
||||
(rightIndex === -1 ? categoryOrder.length : rightIndex) || left.localeCompare(right)
|
||||
);
|
||||
})
|
||||
.map(([category, materials]) => ({ category, materials }));
|
||||
}
|
||||
|
||||
function getSelectedMaterialName(materialPathId: number | null) {
|
||||
if (materialPathId == null || Number(materialPathId) <= 0) return 'Select material';
|
||||
const material = activeMaterials.find(
|
||||
(item: any) => Number(item.id) === Number(materialPathId)
|
||||
);
|
||||
return material ? materialDisplayName(material) : `${materialPathId} - Unknown material`;
|
||||
}
|
||||
|
||||
function openMaterialPicker(stepIndex: number) {
|
||||
materialPickerStepIndex = stepIndex;
|
||||
materialPickerSearch = '';
|
||||
materialPickerOpen = true;
|
||||
}
|
||||
|
||||
function selectMaterialForActiveStep(materialPathId: number) {
|
||||
if (materialPickerStepIndex == null) return;
|
||||
updateRecipeStepNumber(materialPickerStepIndex, 'materialPathId', String(materialPathId));
|
||||
materialPickerOpen = false;
|
||||
materialPickerStepIndex = null;
|
||||
}
|
||||
|
||||
function toppingSlotDisplayName(material: any) {
|
||||
const slot = Number(material?.id) - 8110;
|
||||
const slotName = material?.materialOtherName || material?.materialName;
|
||||
|
|
@ -233,6 +372,94 @@
|
|||
return `${topping?.id ?? '-'} - ${toppingName || 'Unnamed topping'}`;
|
||||
}
|
||||
|
||||
function getSelectedToppingSlotName(slot: number | null) {
|
||||
if (slot == null || !Number.isFinite(Number(slot))) return 'Select slot';
|
||||
const material = activeToppingSlotMaterials.find(
|
||||
(item: any) => Number(item.id) - 8110 === Number(slot)
|
||||
);
|
||||
return material ? toppingSlotDisplayName(material) : `Slot ${slot}`;
|
||||
}
|
||||
|
||||
function getSelectedToppingGroupName(groupID: number | null) {
|
||||
if (groupID == null || !Number.isFinite(Number(groupID))) return 'Select group';
|
||||
const group = activeToppingGroups.find((item: any) => Number(item.groupID) === Number(groupID));
|
||||
return group ? toppingGroupDisplayName(group) : `Group ${groupID}`;
|
||||
}
|
||||
|
||||
function getSelectedToppingListName(toppingID: number | null) {
|
||||
if (toppingID == null || !Number.isFinite(Number(toppingID))) return 'Select topping';
|
||||
const topping = activeToppingLists.find((item: any) => Number(item.id) === Number(toppingID));
|
||||
return topping ? toppingListDisplayName(topping) : `Topping ${toppingID}`;
|
||||
}
|
||||
|
||||
function openToppingPicker(type: ToppingPickerType, optionIndex: number) {
|
||||
toppingPickerType = type;
|
||||
toppingPickerOptionIndex = optionIndex;
|
||||
toppingPickerSearch = '';
|
||||
toppingPickerOpen = true;
|
||||
}
|
||||
|
||||
function getToppingPickerTitle() {
|
||||
if (toppingPickerType === 'slot') return 'Select Topping Slot';
|
||||
if (toppingPickerType === 'group') return 'Select Topping Group';
|
||||
return 'Select Default Topping';
|
||||
}
|
||||
|
||||
function getToppingPickerDescription() {
|
||||
if (toppingPickerType === 'slot') return 'Choose the physical topping slot for this menu.';
|
||||
if (toppingPickerType === 'group') return 'Choose the topping group shown to the customer.';
|
||||
return 'Choose the default selected topping from the selected group.';
|
||||
}
|
||||
|
||||
function getActiveToppingPickerOption() {
|
||||
if (toppingPickerOptionIndex == null) return undefined;
|
||||
return activeForm?.toppingOptions?.[toppingPickerOptionIndex];
|
||||
}
|
||||
|
||||
function getToppingPickerOptions() {
|
||||
const search = toppingPickerSearch.trim().toLowerCase();
|
||||
const currentOption = getActiveToppingPickerOption();
|
||||
const options =
|
||||
toppingPickerType === 'slot'
|
||||
? activeToppingSlotMaterials.map((material: any) => ({
|
||||
value: Number(material.id) - 8110,
|
||||
label: toppingSlotDisplayName(material),
|
||||
description: material.pathOtherName || material.CanisterType || 'Topping material slot'
|
||||
}))
|
||||
: toppingPickerType === 'group'
|
||||
? activeToppingGroups.map((group: any) => ({
|
||||
value: Number(group.groupID),
|
||||
label: toppingGroupDisplayName(group),
|
||||
description: `${getToppingListsForGroup(Number(group.groupID)).length} toppings`
|
||||
}))
|
||||
: getToppingListsForGroup(currentOption?.groupID ?? null).map((topping: any) => ({
|
||||
value: Number(topping.id),
|
||||
label: toppingListDisplayName(topping),
|
||||
description:
|
||||
topping?.description || topping?.otherDescription || 'Default topping option'
|
||||
}));
|
||||
|
||||
return options.filter((option: any) => {
|
||||
if (!search) return true;
|
||||
return `${option.value} ${option.label} ${option.description}`.toLowerCase().includes(search);
|
||||
});
|
||||
}
|
||||
|
||||
function selectToppingPickerOption(value: number) {
|
||||
if (toppingPickerOptionIndex == null) return;
|
||||
|
||||
if (toppingPickerType === 'slot') {
|
||||
updateToppingSlot(toppingPickerOptionIndex, String(value));
|
||||
} else if (toppingPickerType === 'group') {
|
||||
updateToppingGroup(toppingPickerOptionIndex, String(value));
|
||||
} else {
|
||||
updateToppingList(toppingPickerOptionIndex, String(value));
|
||||
}
|
||||
|
||||
toppingPickerOpen = false;
|
||||
toppingPickerOptionIndex = null;
|
||||
}
|
||||
|
||||
function normalizeToppingListIDs(value: any) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
|
||||
|
|
@ -294,7 +521,8 @@
|
|||
|
||||
async function connectAdb() {
|
||||
try {
|
||||
if (adb.getAdbInstance()) {
|
||||
const connected = await adb.ensureAdbConnection();
|
||||
if (connected) {
|
||||
if (!isAdbWriterAvailable()) {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
}
|
||||
|
|
@ -315,6 +543,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function reconnectAndroidSocket() {
|
||||
try {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
if (isAdbWriterAvailable()) {
|
||||
addNotification('INFO:Android socket connected');
|
||||
} else {
|
||||
addNotification('WARN:Android socket not connected');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('failed to reconnect android socket', error);
|
||||
addNotification('WARN:Android socket not connected');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecipeFromMachine() {
|
||||
if (recipeLoading) return;
|
||||
|
||||
|
|
@ -338,7 +580,7 @@
|
|||
addNotification('INFO:Recipe loaded');
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('failed to parse recipe json', recipePath, error);
|
||||
logger.error('failed to parse recipe json', recipePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,11 +606,16 @@
|
|||
// No country file means Thailand
|
||||
detectedCountry = 'THAI';
|
||||
}
|
||||
console.log('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
|
||||
logger.info(
|
||||
'[CreateMenu] Detected country:',
|
||||
detectedCountry,
|
||||
'-> prefix:',
|
||||
detectedCountryCode
|
||||
);
|
||||
} catch (error) {
|
||||
// Error reading file means Thailand (default)
|
||||
detectedCountry = 'THAI';
|
||||
console.log('[CreateMenu] Country file not found, defaulting to THAI');
|
||||
logger.info('[CreateMenu] Country file not found, defaulting to THAI');
|
||||
} finally {
|
||||
countryLoading = false;
|
||||
}
|
||||
|
|
@ -555,8 +802,6 @@
|
|||
otherName: '',
|
||||
description: '',
|
||||
otherDescription: '',
|
||||
cashPrice: '0',
|
||||
nonCashPrice: '0',
|
||||
image: '',
|
||||
isUse: false,
|
||||
recipeSteps: [createEmptyRecipeStep()],
|
||||
|
|
@ -742,6 +987,15 @@
|
|||
).length;
|
||||
}
|
||||
|
||||
function ensureRecipePlaceholders(recipes: any[]) {
|
||||
return [
|
||||
...recipes,
|
||||
...Array.from({ length: Math.max(0, recipeStepTargetCount - recipes.length) }, () =>
|
||||
createPlaceholderRecipeStep()
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function persistStagedMenus() {
|
||||
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
|
||||
void persistStagedMenusToAndroid();
|
||||
|
|
@ -751,8 +1005,9 @@
|
|||
if (!adb.getAdbInstance()) return;
|
||||
|
||||
try {
|
||||
const tempPath = `${stagedMenuAndroidPath}.tmp`;
|
||||
await adb.push(
|
||||
stagedMenuAndroidPath,
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
|
|
@ -763,8 +1018,10 @@
|
|||
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);
|
||||
logger.error('failed to persist staged menus to Android', error);
|
||||
addNotification('WARN:Failed to save draft menus to Android');
|
||||
}
|
||||
}
|
||||
|
|
@ -798,7 +1055,7 @@
|
|||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('failed to load staged menus from Android', error);
|
||||
logger.error('failed to load staged menus from Android', error);
|
||||
addNotification('WARN:Failed to load draft menus from Android');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -811,21 +1068,21 @@
|
|||
}
|
||||
|
||||
function buildMenuFromForm(form: TempFormData) {
|
||||
const toppingSet = form.toppingOptions
|
||||
.filter((opt) => opt.slot != null && opt.groupID != null)
|
||||
.map((opt) => {
|
||||
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
|
||||
const listGroupIDs = getToppingGroupListIDs(group);
|
||||
return {
|
||||
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
|
||||
defaultIDSelect: opt.defaultIDSelect ?? 0,
|
||||
groupID: String(opt.groupID),
|
||||
isUse: true
|
||||
};
|
||||
});
|
||||
const toppingSet = Array.from({ length: toppingSetTargetCount }, () => createEmptyToppingSet());
|
||||
for (const opt of form.toppingOptions) {
|
||||
if (opt.slot == null || opt.groupID == null) continue;
|
||||
|
||||
while (toppingSet.length < 4) {
|
||||
toppingSet.push(createEmptyToppingSet());
|
||||
const index = opt.slot - 1;
|
||||
if (index < 0 || index >= toppingSet.length) continue;
|
||||
|
||||
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
|
||||
const listGroupIDs = getToppingGroupListIDs(group);
|
||||
toppingSet[index] = {
|
||||
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
|
||||
defaultIDSelect: opt.defaultIDSelect ?? 0,
|
||||
groupID: String(opt.groupID),
|
||||
isUse: true
|
||||
};
|
||||
}
|
||||
|
||||
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
|
||||
|
|
@ -840,16 +1097,17 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
const recipes = ensureRecipePlaceholders(recipeSteps);
|
||||
|
||||
return {
|
||||
Description: form.description,
|
||||
ExtendID: 0,
|
||||
OnTOP: false,
|
||||
LastChange: formatAndroidRecipeDate(),
|
||||
MenuStatus: 0,
|
||||
MenuStatus: MenuStatus.drafted,
|
||||
StringParam: ',filter-enable=no,',
|
||||
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
|
||||
cashPrice: Number(form.cashPrice) || 0,
|
||||
cashPrice: 0,
|
||||
changerecipe: '',
|
||||
EncoderCount: 0,
|
||||
id: 0,
|
||||
|
|
@ -857,9 +1115,9 @@
|
|||
productCode: form.productCode,
|
||||
name: form.name,
|
||||
otherName: form.otherName,
|
||||
nonCashPrice: Number(form.nonCashPrice) || 0,
|
||||
nonCashPrice: 0,
|
||||
otherDescription: form.otherDescription,
|
||||
recipes: recipeSteps,
|
||||
recipes,
|
||||
ToppingSet: toppingSet,
|
||||
SubMenu: [],
|
||||
total_time: -1,
|
||||
|
|
@ -870,6 +1128,14 @@
|
|||
};
|
||||
}
|
||||
|
||||
function buildPendingOnlineMenu(menu: any) {
|
||||
return {
|
||||
...menu,
|
||||
MenuStatus: MenuStatus.pendingOnline,
|
||||
recipes: ensureRecipePlaceholders(menu?.recipes ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
async function createMenuDraft() {
|
||||
if (tempForms.length === 0) {
|
||||
addNotification('ERR:No forms to save');
|
||||
|
|
@ -915,11 +1181,6 @@
|
|||
|
||||
async function saveStagedMenuToAndroid(menu: any) {
|
||||
if (!(await ensureAndroidSocket())) return;
|
||||
if (getActiveRecipeStepCount(menu) === 0) {
|
||||
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
|
||||
addNotification(`ERR:Select at least one material before saving: ${menu.productCode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setMenuSaving(menu.productCode);
|
||||
|
||||
|
|
@ -927,7 +1188,7 @@
|
|||
type: 'save_recipe_menu_file',
|
||||
payload: {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: menu
|
||||
data: buildPendingOnlineMenu(menu)
|
||||
}
|
||||
});
|
||||
if (!sent) {
|
||||
|
|
@ -961,19 +1222,7 @@
|
|||
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(
|
||||
logger.info(
|
||||
'[Create Menu] save all draft menus',
|
||||
menus.map((menu) => menu.productCode)
|
||||
);
|
||||
|
|
@ -986,7 +1235,7 @@
|
|||
type: 'save_recipe_menu_file_batch',
|
||||
payload: {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: menus
|
||||
data: menus.map(buildPendingOnlineMenu)
|
||||
}
|
||||
});
|
||||
if (!sent) {
|
||||
|
|
@ -1133,8 +1382,9 @@
|
|||
|
||||
const toppingOptionsFromMenu = (m: any) => {
|
||||
return (m.ToppingSet ?? [])
|
||||
.filter((ts: any) => ts.isUse && Number(ts.groupID) > 0)
|
||||
.map((ts: any, index: number) => ({
|
||||
.map((ts: any, index: number) => ({ ts, index }))
|
||||
.filter(({ ts }: { ts: any }) => ts.isUse && Number(ts.groupID) > 0)
|
||||
.map(({ ts, index }: { ts: any; index: number }) => ({
|
||||
slot: index + 1,
|
||||
groupID: Number(ts.groupID),
|
||||
defaultIDSelect: Number(ts.defaultIDSelect) || null
|
||||
|
|
@ -1149,8 +1399,6 @@
|
|||
otherName: menu.otherName ?? '',
|
||||
description: menu.Description ?? '',
|
||||
otherDescription: menu.otherDescription ?? '',
|
||||
cashPrice: String(menu.cashPrice ?? 0),
|
||||
nonCashPrice: String(menu.nonCashPrice ?? 0),
|
||||
image: menu.uriData?.replace(/^img=/, '') ?? '',
|
||||
isUse: menu.isUse !== false,
|
||||
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
|
||||
|
|
@ -1171,7 +1419,7 @@
|
|||
try {
|
||||
boxid = (await adb.pull('/sdcard/coffeevending/.bid')) || undefined;
|
||||
} catch (e) {
|
||||
console.warn('Failed to get boxid from machine:', e);
|
||||
logger.warn('Failed to get boxid from machine:', e);
|
||||
}
|
||||
}
|
||||
requestListMenu('tha', boxid);
|
||||
|
|
@ -1191,6 +1439,9 @@
|
|||
|
||||
onDestroy(() => {
|
||||
clearOnMenuSavedCallback();
|
||||
// Leaving Create Menu: dismiss coffeemain's RecipeActivity and let the
|
||||
// XMLEngine kiosk home resume (keeps its current portrait orientation).
|
||||
void adb.goToMachineHome();
|
||||
});
|
||||
|
||||
// Auto-load when ADB is connected
|
||||
|
|
@ -1244,11 +1495,6 @@
|
|||
<Button variant="default" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
||||
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
|
||||
</Button>
|
||||
{#if !isAndroidSocketConnected}
|
||||
<Button variant="outline" onclick={() => adb.reconnectAndroidRecipeMenuServer()}
|
||||
>Reconnect Socket</Button
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button variant="default" onclick={openSetupPopup}>+ Create New Menu</Button>
|
||||
<Button variant="outline" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
||||
|
|
@ -1256,6 +1502,26 @@
|
|||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if isAdbConnected}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-center self-center text-xs font-medium text-muted-foreground"
|
||||
onclick={reconnectAndroidSocket}
|
||||
disabled={isAndroidSocketConnected}
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full {isAndroidSocketConnected
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-destructive'}"
|
||||
></span>
|
||||
{isAndroidSocketConnected ? 'Socket Connected' : 'Reconnect Socket'}
|
||||
</Button>
|
||||
{#if !isAndroidSocketConnected}
|
||||
<span class="self-center text-xs text-muted-foreground">Required before saving</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Country indicator -->
|
||||
{#if isAdbConnected}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
|
|
@ -1380,7 +1646,10 @@
|
|||
{#if detectedCountry}
|
||||
<div class="rounded-md border bg-muted/30 p-3">
|
||||
<div class="text-sm text-muted-foreground">Machine Country</div>
|
||||
<div class="font-semibold">{detectedCountry} <span class="text-muted-foreground">(prefix: {detectedCountryCode})</span></div>
|
||||
<div class="font-semibold">
|
||||
{detectedCountry}
|
||||
<span class="text-muted-foreground">(prefix: {detectedCountryCode})</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -1486,11 +1755,11 @@
|
|||
</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for={`name-${activeForm.temp}`}>Name (Thai)</Label>
|
||||
<Label for={`name-${activeForm.temp}`}>Name ({primaryLanguageLabel})</Label>
|
||||
<Input
|
||||
id={`name-${activeForm.temp}`}
|
||||
value={activeForm.name}
|
||||
placeholder="ชื่อเมนู"
|
||||
placeholder={getPrimaryNamePlaceholder()}
|
||||
oninput={(event) => updateActiveFormField('name', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1506,10 +1775,13 @@
|
|||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for={`description-${activeForm.temp}`}>Description (Thai)</Label>
|
||||
<Label for={`description-${activeForm.temp}`}
|
||||
>Description ({primaryLanguageLabel})</Label
|
||||
>
|
||||
<Input
|
||||
id={`description-${activeForm.temp}`}
|
||||
value={activeForm.description}
|
||||
placeholder={getPrimaryDescriptionPlaceholder()}
|
||||
oninput={(event) =>
|
||||
updateActiveFormField('description', event.currentTarget.value)}
|
||||
/>
|
||||
|
|
@ -1524,28 +1796,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for={`cashPrice-${activeForm.temp}`}>Cash price</Label>
|
||||
<Input
|
||||
id={`cashPrice-${activeForm.temp}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={activeForm.cashPrice}
|
||||
oninput={(event) => updateActiveFormField('cashPrice', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for={`nonCashPrice-${activeForm.temp}`}>Non-cash price</Label>
|
||||
<Input
|
||||
id={`nonCashPrice-${activeForm.temp}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={activeForm.nonCashPrice}
|
||||
oninput={(event) =>
|
||||
updateActiveFormField('nonCashPrice', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for={`image-${activeForm.temp}`}>Image file</Label>
|
||||
<Input
|
||||
|
|
@ -1611,23 +1862,14 @@
|
|||
<div class="grid gap-3">
|
||||
<div class="grid gap-2">
|
||||
<Label>Material</Label>
|
||||
<select
|
||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={step.materialPathId == null ? '' : String(step.materialPathId)}
|
||||
onchange={(event) =>
|
||||
updateRecipeStepNumber(
|
||||
index,
|
||||
'materialPathId',
|
||||
event.currentTarget.value
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||
onclick={() => openMaterialPicker(index)}
|
||||
>
|
||||
<option value="" disabled>Select material</option>
|
||||
{#each activeMaterials as material}
|
||||
<option value={String(material.id)}
|
||||
>{materialDisplayName(material)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{getSelectedMaterialName(step.materialPathId)}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-4">
|
||||
<div class="grid gap-2">
|
||||
|
|
@ -1765,51 +2007,37 @@
|
|||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="grid gap-2">
|
||||
<Label>Slot</Label>
|
||||
<select
|
||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={topping.slot == null ? '' : String(topping.slot)}
|
||||
onchange={(e) => updateToppingSlot(index, e.currentTarget.value)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||
onclick={() => openToppingPicker('slot', index)}
|
||||
>
|
||||
<option value="" disabled>Select slot</option>
|
||||
{#each activeToppingSlotMaterials as material}
|
||||
<option value={String(Number(material.id) - 8110)}>
|
||||
{toppingSlotDisplayName(material)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{getSelectedToppingSlotName(topping.slot)}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>Topping group</Label>
|
||||
<select
|
||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={topping.groupID == null ? '' : String(topping.groupID)}
|
||||
onchange={(e) => updateToppingGroup(index, e.currentTarget.value)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||
onclick={() => openToppingPicker('group', index)}
|
||||
>
|
||||
<option value="" disabled>Select group</option>
|
||||
{#each activeToppingGroups as group}
|
||||
<option value={String(group.groupID)}
|
||||
>{toppingGroupDisplayName(group)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{getSelectedToppingGroupName(topping.groupID)}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>Default topping</Label>
|
||||
<select
|
||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={topping.defaultIDSelect == null
|
||||
? ''
|
||||
: String(topping.defaultIDSelect)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||
disabled={topping.groupID == null}
|
||||
onchange={(e) => updateToppingList(index, e.currentTarget.value)}
|
||||
onclick={() => openToppingPicker('list', index)}
|
||||
>
|
||||
<option value="" disabled>Select topping</option>
|
||||
{#each getToppingListsForGroup(topping.groupID) as toppingItem}
|
||||
<option value={String(toppingItem.id)}
|
||||
>{toppingListDisplayName(toppingItem)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{getSelectedToppingListName(topping.defaultIDSelect)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1830,6 +2058,96 @@
|
|||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Material Picker Dialog -->
|
||||
<Dialog.Root bind:open={materialPickerOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Select Material</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Choose a material for recipe step {materialPickerStepIndex == null
|
||||
? ''
|
||||
: materialPickerStepIndex + 1}. Materials are grouped by channel/category.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-2">
|
||||
<Input bind:value={materialPickerSearch} placeholder="Search material id, name, path, type" />
|
||||
|
||||
{#if groupedMaterialOptions.length === 0}
|
||||
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No materials found.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid max-h-[60vh] gap-4 overflow-y-auto pr-1">
|
||||
{#each groupedMaterialOptions as group}
|
||||
<div class="rounded-md border">
|
||||
<div class="flex items-center justify-between border-b bg-muted/40 px-3 py-2">
|
||||
<div class="text-sm font-semibold">{group.category}</div>
|
||||
<div class="text-xs text-muted-foreground">{group.materials.length} items</div>
|
||||
</div>
|
||||
<div class="grid divide-y">
|
||||
{#each group.materials as material}
|
||||
<button
|
||||
type="button"
|
||||
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
|
||||
onclick={() => selectMaterialForActiveStep(Number(material.id))}
|
||||
>
|
||||
<div class="font-medium">{materialDisplayName(material)}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{material.pathOtherName || material.CanisterType || 'No path/type'}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (materialPickerOpen = false)}>Cancel</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Topping Picker Dialog -->
|
||||
<Dialog.Root bind:open={toppingPickerOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{getToppingPickerTitle()}</Dialog.Title>
|
||||
<Dialog.Description>{getToppingPickerDescription()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-2">
|
||||
<Input bind:value={toppingPickerSearch} placeholder="Search id, name, description" />
|
||||
|
||||
{#if toppingPickerOptions.length === 0}
|
||||
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{toppingPickerType === 'list' ? 'No toppings found for this group.' : 'No options found.'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid max-h-[60vh] divide-y overflow-y-auto rounded-md border">
|
||||
{#each toppingPickerOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
|
||||
onclick={() => selectToppingPickerOption(option.value)}
|
||||
>
|
||||
<div class="font-medium">{option.label}</div>
|
||||
<div class="text-xs text-muted-foreground">{option.description}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (toppingPickerOpen = false)}>Cancel</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Brew Confirm Dialog -->
|
||||
<Dialog.Root bind:open={brewConfirmOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
|
@ -171,7 +172,7 @@
|
|||
} catch (error) {
|
||||
files[index].status = 'error';
|
||||
files[index].error = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`Upload error for ${item.file.name}:`, error);
|
||||
logger.error(`Upload error for ${item.file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
797
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
797
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import Progress from '$lib/components/ui/progress/progress.svelte';
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
Film,
|
||||
MonitorPlay,
|
||||
CoffeeIcon,
|
||||
Pencil,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ImageIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { AdbInstance } from '../../../state.svelte';
|
||||
|
||||
const CREATE_ENDPOINT = '/api/video-mainpage';
|
||||
const LIST_ENDPOINT = '/api/video-mainpage/list';
|
||||
const UPDATE_ENDPOINT = '/api/video-mainpage/update';
|
||||
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
|
||||
const GET_IMAGE = env.PUBLIC_GET_IMAGE;
|
||||
const DURATION_TRIM = 4; // brewing play seconds = video length − 4
|
||||
|
||||
// taobin_project-relative path -> served URL (works for video/ and inter/<c>/video/).
|
||||
const videoUrl = (path: string) => `${GET_IMAGE}/${path}`;
|
||||
|
||||
// Only Thailand is enabled for now. To re-enable a country later, uncomment it
|
||||
// here (the backend already supports inter/<country>/video for all of these).
|
||||
const COUNTRIES = [
|
||||
{ value: 'tha', label: 'Thailand (tha)' }
|
||||
// { value: 'aus', label: 'Australia (aus)' },
|
||||
// { value: 'gbr', label: 'United Kingdom (gbr)' },
|
||||
// { value: 'gbr_premium', label: 'UK Premium (gbr_premium)' },
|
||||
// { value: 'hkg', label: 'Hong Kong (hkg)' },
|
||||
// { value: 'ltu', label: 'Lithuania (ltu)' },
|
||||
// { value: 'mys', label: 'Malaysia (mys)' },
|
||||
// { value: 'rou', label: 'Romania (rou)' },
|
||||
// { value: 'sgp', label: 'Singapore (sgp)' },
|
||||
// { value: 'tha_premium', label: 'Thailand Premium (tha_premium)' },
|
||||
// { value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' },
|
||||
// { value: 'usa', label: 'USA (usa)' }
|
||||
];
|
||||
let country = $state('tha');
|
||||
const countryLabel = $derived(COUNTRIES.find((c) => c.value === country)?.label ?? country);
|
||||
|
||||
interface MediaInfo {
|
||||
filename: string;
|
||||
video: string;
|
||||
size: number | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
interface ManagedVideo {
|
||||
n: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
range_label: string;
|
||||
main: MediaInfo | null;
|
||||
brewing: MediaInfo | null;
|
||||
editable: true;
|
||||
}
|
||||
interface ReadonlyVideo {
|
||||
filename: string;
|
||||
video: string;
|
||||
size: number | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ── create form ─────────────────────────────────────────────────────────
|
||||
let name = $state('');
|
||||
let startDate = $state('');
|
||||
let endDate = $state('');
|
||||
let mainFile = $state<File | null>(null);
|
||||
let mainPreview = $state('');
|
||||
let brewingFile = $state<File | null>(null);
|
||||
let brewingPreview = $state('');
|
||||
let brewingRawSeconds = $state(0);
|
||||
let brewingTxtFile = $state<File | null>(null);
|
||||
let brewingTxtPreview = $state('');
|
||||
let brewingTxtEnFile = $state<File | null>(null);
|
||||
let brewingTxtEnPreview = $state('');
|
||||
|
||||
const brewingPlaySeconds = $derived(Math.max(1, Math.round(brewingRawSeconds) - DURATION_TRIM));
|
||||
|
||||
let submitting = $state(false);
|
||||
let connecting = $state(false);
|
||||
let pushProgress = $state({ percent: 0, name: '', active: false });
|
||||
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||||
|
||||
// ── list ────────────────────────────────────────────────────────────────
|
||||
let managed = $state<ManagedVideo[]>([]);
|
||||
let readonlyList = $state<ReadonlyVideo[]>([]);
|
||||
let loadingList = $state(false);
|
||||
let showReadonly = $state(false);
|
||||
|
||||
// ── edit dialog ───────────────────────────────────────────────────────────
|
||||
let editOpen = $state(false);
|
||||
let editTarget = $state<ManagedVideo | null>(null);
|
||||
let editName = $state('');
|
||||
let editStart = $state('');
|
||||
let editEnd = $state('');
|
||||
let editMainFile = $state<File | null>(null);
|
||||
let editBrewingFile = $state<File | null>(null);
|
||||
let editBrewingRaw = $state(0);
|
||||
let editBrewingTxtFile = $state<File | null>(null);
|
||||
let editBrewingTxtEnFile = $state<File | null>(null);
|
||||
let editSaving = $state(false);
|
||||
|
||||
const editBrewingPlaySeconds = $derived(Math.max(1, Math.round(editBrewingRaw) - DURATION_TRIM));
|
||||
|
||||
function toIso(d: string): string {
|
||||
return d ? `${d}T00:00:00` : '';
|
||||
}
|
||||
|
||||
function fmtMB(bytes: number | null | undefined): string {
|
||||
return bytes ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : '—';
|
||||
}
|
||||
|
||||
function readVideoDuration(file: File): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const v = document.createElement('video');
|
||||
v.preload = 'metadata';
|
||||
const url = URL.createObjectURL(file);
|
||||
v.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(Number.isFinite(v.duration) ? v.duration : 0);
|
||||
};
|
||||
v.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(0);
|
||||
};
|
||||
v.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function machineVideoNumbers(): Promise<string> {
|
||||
try {
|
||||
const res = await adb.executeCmd(`ls ${MACHINE_PROJECT_DIR}/video`);
|
||||
const out = typeof res === 'object' && res ? ((res as { output?: string }).output ?? '') : '';
|
||||
const nums = [...out.matchAll(/brewing_adv(\d+)/g)].map((m) => m[1]);
|
||||
return [...new Set(nums)].join(',');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function connectMachine() {
|
||||
if (AdbInstance.instance) {
|
||||
addNotification('INFO:Machine already connected');
|
||||
return;
|
||||
}
|
||||
connecting = true;
|
||||
try {
|
||||
await adb.connnectViaWebUSB(false);
|
||||
addNotification(AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected');
|
||||
} catch (error) {
|
||||
addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMain(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||
mainFile = file;
|
||||
mainPreview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
async function pickBrewing(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||
brewingFile = file;
|
||||
brewingPreview = URL.createObjectURL(file);
|
||||
brewingRawSeconds = await readVideoDuration(file);
|
||||
}
|
||||
|
||||
function pickPng(event: Event, set: (f: File, url: string) => void) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||
set(file, URL.createObjectURL(file));
|
||||
}
|
||||
function pickBrewingTxt(e: Event) {
|
||||
pickPng(e, (f, url) => {
|
||||
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||
brewingTxtFile = f;
|
||||
brewingTxtPreview = url;
|
||||
});
|
||||
}
|
||||
function pickBrewingTxtEn(e: Event) {
|
||||
pickPng(e, (f, url) => {
|
||||
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||
brewingTxtEnFile = f;
|
||||
brewingTxtEnPreview = url;
|
||||
});
|
||||
}
|
||||
|
||||
function clearMain() {
|
||||
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||
mainFile = null;
|
||||
mainPreview = '';
|
||||
}
|
||||
function clearBrewing() {
|
||||
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||
brewingFile = null;
|
||||
brewingPreview = '';
|
||||
brewingRawSeconds = 0;
|
||||
}
|
||||
function clearBrewingTxt() {
|
||||
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||
brewingTxtFile = null;
|
||||
brewingTxtPreview = '';
|
||||
}
|
||||
function clearBrewingTxtEn() {
|
||||
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||
brewingTxtEnFile = null;
|
||||
brewingTxtEnPreview = '';
|
||||
}
|
||||
|
||||
async function pushBinaries(items: { rel: string; file: File }[]) {
|
||||
const dirs = [...new Set(items.map((it) => it.rel.slice(0, it.rel.lastIndexOf('/'))))];
|
||||
for (const d of dirs) await adb.executeCmd(`mkdir -p "${MACHINE_PROJECT_DIR}/${d}"`);
|
||||
for (const it of items) {
|
||||
const label = it.rel.split('/').pop() ?? it.rel;
|
||||
pushProgress = { percent: 0, name: label, active: true };
|
||||
const bytes = new Uint8Array(await it.file.arrayBuffer());
|
||||
const ok = await adb.pushBinary(`${MACHINE_PROJECT_DIR}/${it.rel}`, bytes, (sent, total) => {
|
||||
pushProgress = {
|
||||
percent: total > 0 ? Math.round((sent / total) * 100) : 0,
|
||||
name: label,
|
||||
active: true
|
||||
};
|
||||
});
|
||||
if (!ok) throw new Error(`push ${label} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Push each uploaded file to every target path the backend returned (one per base).
|
||||
function targetItems(
|
||||
targets: Record<string, string[]>,
|
||||
files: Record<string, File | null>
|
||||
): { rel: string; file: File }[] {
|
||||
const items: { rel: string; file: File }[] = [];
|
||||
for (const kind of ['main', 'brewing', 'txt', 'txt_en']) {
|
||||
const f = files[kind];
|
||||
if (!f) continue;
|
||||
for (const rel of targets?.[kind] ?? []) items.push({ rel, file: f });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
async function pushScripts(scripts: { path: string; content: string }[]) {
|
||||
for (const s of scripts) {
|
||||
if (!s?.path || typeof s?.content !== 'string') continue;
|
||||
pushProgress = { percent: 100, name: s.path.split('/').pop() ?? s.path, active: true };
|
||||
await adb.push(`${MACHINE_PROJECT_DIR}/${s.path}`, s.content);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const user = $auth;
|
||||
if (!user) return addNotification('ERR:Not logged in');
|
||||
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
|
||||
if (!name.trim() || !startDate || !mainFile || !brewingFile || !brewingTxtFile || !brewingTxtEnFile)
|
||||
return addNotification(
|
||||
'ERR:Need a name, start date, both videos, and both brewing text overlays (TH + EN)'
|
||||
);
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('uid', user.uid);
|
||||
fd.append('displayName', user.displayName || 'unknown');
|
||||
fd.append('email', user.email || 'unknown@email.com');
|
||||
fd.append('name', name.trim());
|
||||
fd.append('country', country);
|
||||
fd.append('start', toIso(startDate));
|
||||
fd.append('end', endDate ? toIso(endDate) : 'NONE');
|
||||
fd.append('machine_numbers', await machineVideoNumbers());
|
||||
fd.append('brewing_duration', String(brewingPlaySeconds));
|
||||
fd.append('video', mainFile);
|
||||
fd.append('brewing_video', brewingFile);
|
||||
fd.append('brewing_txt', brewingTxtFile);
|
||||
fd.append('brewing_txt_en', brewingTxtEnFile);
|
||||
|
||||
const res = await fetch(CREATE_ENDPOINT, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(e.detail || e.message || 'Add video failed');
|
||||
}
|
||||
const result = await res.json();
|
||||
|
||||
await pushBinaries(
|
||||
targetItems(result.targets, {
|
||||
main: mainFile,
|
||||
brewing: brewingFile,
|
||||
txt: brewingTxtFile,
|
||||
txt_en: brewingTxtEnFile
|
||||
})
|
||||
);
|
||||
await pushScripts(result?.content?.scripts ?? []);
|
||||
|
||||
if (result?.sftp?.error)
|
||||
addNotification(`WARN:Uploaded but FTP sync failed: ${result.sftp.error}`);
|
||||
addNotification(`INFO:Added "${name.trim()}" as brewing_adv${result.n} (${country})`);
|
||||
clearMain();
|
||||
clearBrewing();
|
||||
clearBrewingTxt();
|
||||
clearBrewingTxtEn();
|
||||
name = '';
|
||||
startDate = '';
|
||||
endDate = '';
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
pushProgress = { percent: 0, name: '', active: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loadingList = true;
|
||||
try {
|
||||
const res = await fetch(`${LIST_ENDPOINT}?country=${encodeURIComponent(country)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) throw new Error('list failed');
|
||||
const data = await res.json();
|
||||
managed = data.managed ?? [];
|
||||
readonlyList = data.readonly ?? [];
|
||||
} catch (error) {
|
||||
addNotification(`ERR:Load list failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
loadingList = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(v: ManagedVideo) {
|
||||
editTarget = v;
|
||||
editName = v.name;
|
||||
editStart = v.start ? v.start.slice(0, 10) : '';
|
||||
editEnd = v.end && v.end !== 'NONE' ? v.end.slice(0, 10) : '';
|
||||
editMainFile = null;
|
||||
editBrewingFile = null;
|
||||
editBrewingRaw = 0;
|
||||
editBrewingTxtFile = null;
|
||||
editBrewingTxtEnFile = null;
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
function pickEditMain(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file && file.name.toLowerCase().endsWith('.mp4')) editMainFile = file;
|
||||
else if (file) addNotification('WARN:Only .mp4 allowed');
|
||||
}
|
||||
async function pickEditBrewing(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||
editBrewingFile = file;
|
||||
editBrewingRaw = await readVideoDuration(file);
|
||||
}
|
||||
function pickEditTxt(event: Event, en: boolean) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||
if (en) editBrewingTxtEnFile = file;
|
||||
else editBrewingTxtFile = file;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
const user = $auth;
|
||||
if (!user || !editTarget) return;
|
||||
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
|
||||
if (!editStart) return addNotification('ERR:Start date required');
|
||||
const target = editTarget;
|
||||
editSaving = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('slug', target.slug);
|
||||
fd.append('uid', user.uid);
|
||||
fd.append('displayName', user.displayName || 'unknown');
|
||||
fd.append('email', user.email || 'unknown@email.com');
|
||||
fd.append('country', country);
|
||||
fd.append('name', editName.trim() || target.name);
|
||||
fd.append('start', toIso(editStart));
|
||||
fd.append('end', editEnd ? toIso(editEnd) : 'NONE');
|
||||
if (editBrewingFile) fd.append('brewing_duration', String(editBrewingPlaySeconds));
|
||||
else if (target.brewing?.duration) fd.append('brewing_duration', String(target.brewing.duration));
|
||||
if (editMainFile) fd.append('video', editMainFile);
|
||||
if (editBrewingFile) fd.append('brewing_video', editBrewingFile);
|
||||
if (editBrewingTxtFile) fd.append('brewing_txt', editBrewingTxtFile);
|
||||
if (editBrewingTxtEnFile) fd.append('brewing_txt_en', editBrewingTxtEnFile);
|
||||
|
||||
const res = await fetch(UPDATE_ENDPOINT, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(e.detail || 'Update failed');
|
||||
}
|
||||
const result = await res.json();
|
||||
|
||||
await pushBinaries(
|
||||
targetItems(result.targets ?? {}, {
|
||||
main: editMainFile,
|
||||
brewing: editBrewingFile,
|
||||
txt: editBrewingTxtFile,
|
||||
txt_en: editBrewingTxtEnFile
|
||||
})
|
||||
);
|
||||
await pushScripts(result?.content?.scripts ?? []);
|
||||
|
||||
if (result?.sftp?.error)
|
||||
addNotification(`WARN:Updated but FTP sync failed: ${result.sftp.error}`);
|
||||
addNotification(`INFO:Updated "${target.name}" (${country})`);
|
||||
editOpen = false;
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
|
||||
} finally {
|
||||
editSaving = false;
|
||||
pushProgress = { percent: 0, name: '', active: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Load (and reload) the list whenever the selected country changes; runs on mount.
|
||||
$effect(() => {
|
||||
void country;
|
||||
loadList();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex items-center justify-between px-8 py-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Advertisement Videos</h1>
|
||||
<p class="text-sm text-muted-foreground">Main-page & brewing-page videos, scheduled by date</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
|
||||
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
|
||||
</Badge>
|
||||
{#if !isAdbConnected}
|
||||
<Button variant="outline" onclick={connectMachine} disabled={connecting}>
|
||||
{#if connecting}<Spinner class="mr-2 h-4 w-4" />Connecting...{:else}<MonitorPlay class="mr-2 h-4 w-4" />Connect Machine{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
<div class="mx-auto max-w-5xl space-y-8">
|
||||
<!-- Create -->
|
||||
<Card.Root class="overflow-hidden shadow-sm">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 text-lg">
|
||||
<Upload class="h-5 w-5 text-muted-foreground" /> Add a new video
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Upload the same clip twice — the main-page version and the brewing-page
|
||||
<code class="font-mono">_long</code> version. Auto-named
|
||||
<code class="font-mono">brewing_adv<N></code> (next free 1–40, never overwrites a video in use).
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<Select.Root type="single" bind:value={country}>
|
||||
<Select.Trigger class="w-full">{countryLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each COUNTRIES as c (c.value)}
|
||||
<Select.Item value={c.value}>{c.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Writes to <code class="font-mono">inter/{country}/video</code>{country === 'tha' ? ' + flat video/' : ''}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-name">Name</Label>
|
||||
<Input id="v-name" bind:value={name} placeholder="e.g. Bas Bew Bow Brewing" />
|
||||
<p class="text-xs text-muted-foreground">Used for the comment & the <code class="font-mono">…VideoEnable</code> variable.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-start" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> Start date</Label>
|
||||
<Input id="v-start" type="date" bind:value={startDate} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-end" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> End date <span class="text-muted-foreground">(optional)</span></Label>
|
||||
<Input id="v-end" type="date" bind:value={endDate} />
|
||||
{#if !endDate}<p class="text-xs text-muted-foreground">Blank = open-ended</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- two upload tiles -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- main -->
|
||||
<div class="rounded-xl border p-3">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<Film class="h-4 w-4 text-muted-foreground" /> Main-page video
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>.mp4</Badge>
|
||||
</div>
|
||||
{#if mainFile}
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={mainPreview} class="h-full w-full object-contain" muted controls></video>
|
||||
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearMain}><X class="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={mainFile.name}>{mainFile.name} · {fmtMB(mainFile.size)}</p>
|
||||
{:else}
|
||||
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickMain} />
|
||||
<Film class="mb-2 h-8 w-8" />
|
||||
<span class="text-sm font-medium">Click to select .mp4</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- brewing -->
|
||||
<div class="rounded-xl border p-3">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<CoffeeIcon class="h-4 w-4 text-muted-foreground" /> Brewing-page video
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>_long.mp4</Badge>
|
||||
</div>
|
||||
{#if brewingFile}
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={brewingPreview} class="h-full w-full object-contain" muted controls></video>
|
||||
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearBrewing}><X class="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={brewingFile.name}>{brewingFile.name} · {fmtMB(brewingFile.size)}</p>
|
||||
<p class="mt-1 flex items-center gap-1.5 text-xs font-medium">
|
||||
<Clock class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Plays {brewingPlaySeconds}s
|
||||
<span class="text-muted-foreground">(length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})</span>
|
||||
</p>
|
||||
{:else}
|
||||
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickBrewing} />
|
||||
<CoffeeIcon class="mb-2 h-8 w-8" />
|
||||
<span class="text-sm font-medium">Click to select _long .mp4</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<!-- text overlays (required) -->
|
||||
<div class="mt-3 border-t pt-3">
|
||||
<p class="mb-2 flex items-center gap-1.5 text-xs font-semibold">
|
||||
<ImageIcon class="h-3.5 w-3.5 text-muted-foreground" /> Text overlays (.png, required)
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each [{ label: 'Thai', name: 'brewing_txt_adv<N>.png', file: brewingTxtFile, preview: brewingTxtPreview, pick: pickBrewingTxt, clear: clearBrewingTxt }, { label: 'English', name: 'brewing_txt_adv<N>_en.png', file: brewingTxtEnFile, preview: brewingTxtEnPreview, pick: pickBrewingTxtEn, clear: clearBrewingTxtEn }] as t (t.label)}
|
||||
<div>
|
||||
<p class="mb-1 text-[11px] font-medium text-muted-foreground">{t.label}</p>
|
||||
{#if t.file}
|
||||
<div class="relative overflow-hidden rounded-md border bg-muted/30">
|
||||
<img src={t.preview} alt={t.label} class="h-20 w-full object-contain" />
|
||||
<button class="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white" onclick={t.clear}><X class="h-3 w-3" /></button>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={t.file.name}>{t.file.name}</p>
|
||||
{:else}
|
||||
<label class="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-[11px] text-muted-foreground transition hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={t.pick} />
|
||||
<ImageIcon class="h-4 w-4" /> {t.label} .png
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pushProgress.active && submitting}
|
||||
<div class="space-y-1">
|
||||
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="justify-end gap-2 border-t bg-muted/30 py-4">
|
||||
<Button size="lg" onclick={handleSubmit} disabled={submitting || !isAdbConnected}>
|
||||
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Create & Push{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Existing -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Existing videos</h2>
|
||||
<Button variant="ghost" size="sm" onclick={loadList} disabled={loadingList}>
|
||||
{#if loadingList}<Spinner class="mr-2 h-3.5 w-3.5" />{:else}<RefreshCw class="mr-2 h-3.5 w-3.5" />{/if}Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- managed -->
|
||||
{#if managed.length === 0}
|
||||
<p class="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No web-managed videos yet. Ones you add above appear here and can be edited.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{#each managed as v (v.slug)}
|
||||
<Card.Root class="overflow-hidden">
|
||||
<div class="grid grid-cols-2 gap-px bg-border">
|
||||
<div class="bg-black">
|
||||
{#if v.main}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.main.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||
{:else}
|
||||
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no main</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-black">
|
||||
{#if v.brewing}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.brewing.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||
{:else}
|
||||
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no brewing</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold" title={v.name}>{v.name}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<Badge variant="secondary" class="font-mono">brewing_adv{v.n}</Badge>
|
||||
<span class="flex items-center gap-1"><CalendarDays class="h-3 w-3" />{v.range_label}</span>
|
||||
{#if v.brewing?.duration}<span class="flex items-center gap-1"><Clock class="h-3 w-3" />{v.brewing.duration}s</span>{/if}
|
||||
<Badge variant={v.main ? 'default' : 'outline'} class="text-[10px]">main</Badge>
|
||||
<Badge variant={v.brewing ? 'default' : 'outline'} class="text-[10px]">brewing</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => openEdit(v)}>
|
||||
<Pencil class="mr-1.5 h-3.5 w-3.5" />Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- read-only -->
|
||||
<div class="rounded-lg border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 p-3 text-sm font-semibold text-muted-foreground transition hover:bg-muted/40"
|
||||
onclick={() => (showReadonly = !showReadonly)}
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 shrink-0 transition-transform {showReadonly ? 'rotate-180' : ''}" />
|
||||
<Lock class="h-3.5 w-3.5" />
|
||||
Hand-maintained videos ({readonlyList.length})
|
||||
<Badge variant="secondary" class="ml-1 text-[10px]">read-only</Badge>
|
||||
<span class="ml-auto text-xs font-normal">{showReadonly ? 'Click to hide' : 'Click to show'}</span>
|
||||
</button>
|
||||
{#if showReadonly}
|
||||
<div class="grid grid-cols-2 gap-3 p-3 pt-0 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each readonlyList as v (v.filename)}
|
||||
<div class="overflow-hidden rounded-md border bg-muted/30">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.video)} class="aspect-video w-full bg-black object-contain" preload="metadata" muted controls></video>
|
||||
<p class="truncate px-1.5 py-1 font-mono text-[10px]" title={v.filename}>{v.filename}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content class="sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit video</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if editTarget}<code class="font-mono">brewing_adv{editTarget.n}</code> · change dates, rename, or replace either clip{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="e-name">Name</Label>
|
||||
<Input id="e-name" bind:value={editName} />
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="e-start">Start date</Label>
|
||||
<Input id="e-start" type="date" bind:value={editStart} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="e-end">End date <span class="text-muted-foreground">(optional)</span></Label>
|
||||
<Input id="e-end" type="date" bind:value={editEnd} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border p-2">
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><Film class="h-3.5 w-3.5 text-muted-foreground" /> Main-page</p>
|
||||
{#if editTarget?.main}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(editTarget.main.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||
{/if}
|
||||
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditMain} />
|
||||
<Upload class="h-3.5 w-3.5" /> {editMainFile ? editMainFile.name : 'Replace (optional)'}
|
||||
</label>
|
||||
</div>
|
||||
<div class="rounded-lg border p-2">
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><CoffeeIcon class="h-3.5 w-3.5 text-muted-foreground" /> Brewing-page</p>
|
||||
{#if editTarget?.brewing}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(editTarget.brewing.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||
{/if}
|
||||
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditBrewing} />
|
||||
<Upload class="h-3.5 w-3.5" /> {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
|
||||
</label>
|
||||
{#if editBrewingFile}
|
||||
<p class="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground"><Clock class="h-3 w-3" />Plays {editBrewingPlaySeconds}s</p>
|
||||
{/if}
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, false)} />
|
||||
<ImageIcon class="h-3 w-3" /> {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, true)} />
|
||||
<ImageIcon class="h-3 w-3" /> {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pushProgress.active}
|
||||
<div class="space-y-1">
|
||||
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (editOpen = false)} disabled={editSaving}>Cancel</Button>
|
||||
<Button onclick={submitEdit} disabled={editSaving || !isAdbConnected}>
|
||||
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Save & Push{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<!-- This is common file and will render through all pages -->
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import AnnouncementDialog from '$lib/components/AnnouncementDialog.svelte';
|
||||
import RemoteShellConfirmDialog from '$lib/components/RemoteShellConfirmDialog.svelte';
|
||||
import * as semver from 'semver';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
|
|
@ -41,7 +43,7 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log('base url', window.location.origin, document.cookie);
|
||||
logger.info('base url', window.location.origin, document.cookie);
|
||||
|
||||
onAuthStateChanged(auth, async function (s) {
|
||||
authStore.set(s);
|
||||
|
|
@ -60,14 +62,14 @@
|
|||
}
|
||||
});
|
||||
return authStore.subscribe(async function (user) {
|
||||
// console.log(`store get ${JSON.stringify(user)}`);
|
||||
// logger.info(`store get ${JSON.stringify(user)}`);
|
||||
|
||||
// reloading permissions
|
||||
if (get(currentPermissions).length == 0 && user != null) {
|
||||
// need update
|
||||
let currentUser = user;
|
||||
currentPermissions.set(await getUserPermission(currentUser));
|
||||
console.log('reloading permissions ... ');
|
||||
logger.info('reloading permissions ... ');
|
||||
}
|
||||
|
||||
if (!get(departmentStore)) {
|
||||
|
|
@ -77,7 +79,7 @@
|
|||
: extractCookieOnNonBrowser()['department'];
|
||||
if (saved) {
|
||||
departmentStore.set(saved.value);
|
||||
console.log('get dep', get(departmentStore));
|
||||
logger.info('get dep', get(departmentStore));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -93,4 +95,5 @@
|
|||
<ModeWatcher />
|
||||
<Toaster />
|
||||
<AnnouncementDialog />
|
||||
<RemoteShellConfirmDialog />
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
onMount(() => {
|
||||
let existed_account = get(authStore);
|
||||
let current_user_logged = auth.currentUser;
|
||||
console.log(
|
||||
logger.info(
|
||||
`has acc ${JSON.stringify(existed_account)}, from session: ${JSON.stringify(current_user_logged)}`
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { verifyAuthToken } from '$lib/server/auth';
|
||||
|
||||
// Method 2: forward a machine-generated sync_1.file to the adv FTP server.
|
||||
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
// Verify authentication
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const country = formData.get('country') as string;
|
||||
|
|
@ -33,7 +38,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
|
||||
return json(await response.json());
|
||||
} catch (err) {
|
||||
console.error('[Adv Manifest Proxy] Error:', err);
|
||||
logger.error('[Adv Manifest Proxy] Error:', err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { verifyAuthToken } from '$lib/server/auth';
|
||||
|
||||
// Adv videos are served by the same taobin_image service as menu images.
|
||||
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'video/mp4', 'video/webm', 'video/ogg',
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
|
||||
];
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
// Verify authentication
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const country = formData.get('country') as string;
|
||||
|
|
@ -21,11 +31,16 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
throw error(400, 'Missing required fields');
|
||||
}
|
||||
|
||||
// File type validation (size limit removed — upstream handles it)
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
throw error(400, `File type "${file.type}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
`${ADV_API_BASE}/adv/upload/${encodeURIComponent(country)}/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}` +
|
||||
`?regenerate=${encodeURIComponent(regenerate)}`;
|
||||
|
||||
console.log('[Adv Upload Proxy] Endpoint:', endpoint, 'file:', file.name);
|
||||
logger.info('[Adv Upload Proxy] Endpoint:', endpoint, 'file:', file.name);
|
||||
|
||||
// Upstream expects the multipart field name `files`.
|
||||
const uploadFormData = new FormData();
|
||||
|
|
@ -44,7 +59,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
const result = await response.json();
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
console.error('[Adv Upload Proxy] Error:', err);
|
||||
logger.error('[Adv Upload Proxy] Error:', err);
|
||||
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
|
|
|
|||
24
src/routes/api/catalog-banner-image/+server.ts
Normal file
24
src/routes/api/catalog-banner-image/+server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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'
|
||||
}
|
||||
});
|
||||
};
|
||||
40
src/routes/api/catalog-banner/+server.ts
Normal file
40
src/routes/api/catalog-banner/+server.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
62
src/routes/api/catalog-create/+server.ts
Normal file
62
src/routes/api/catalog-create/+server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
26
src/routes/api/catalog-list/+server.ts
Normal file
26
src/routes/api/catalog-list/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { verifyAuthToken } from '$lib/server/auth';
|
||||
|
||||
const IMAGE_API_BASE = env.PUBLIC_POST_IMAGE;
|
||||
|
||||
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
// Verify authentication
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const country = formData.get('country') as string;
|
||||
|
|
@ -18,6 +25,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
if (!folder || !uid || !displayName || !email || !file) {
|
||||
throw error(400, 'Missing required fields');
|
||||
}
|
||||
|
||||
// File type validation (size limit removed — upstream handles it)
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
throw error(400, `File type "${file.type}" not allowed. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
// Build the upload endpoint
|
||||
|
|
@ -28,7 +40,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
endpoint = `${IMAGE_API_BASE}/image/${encodeURIComponent(folder)}/upload/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
|
||||
}
|
||||
|
||||
console.log('[Image Upload Proxy] Endpoint:', endpoint);
|
||||
logger.info('[Image Upload Proxy] Endpoint:', endpoint);
|
||||
|
||||
// Create new FormData for the upstream request
|
||||
const uploadFormData = new FormData();
|
||||
|
|
@ -47,7 +59,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
const result = await response.json();
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
console.error('[Image Upload Proxy] Error:', err);
|
||||
logger.error('[Image Upload Proxy] Error:', err);
|
||||
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,50 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { verifyAuthToken } from '$lib/server/auth';
|
||||
|
||||
// In-memory store for streamed catalog data
|
||||
// Format: { batchId: { chunks: [...], status: 'collecting'|'complete'|'error' } }
|
||||
const streamCache = new Map<string, any>();
|
||||
const MAX_CACHE_SIZE = 200; // max number of batches in cache
|
||||
const MAX_CHUNKS_PER_BATCH = 500;
|
||||
|
||||
export async function POST({ request }) {
|
||||
export async function POST({ request }: RequestEvent) {
|
||||
try {
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const data = await request.json();
|
||||
const { batch_id, msg, content, current_chunk, total_chunks } = data.payload;
|
||||
const { batch_id, msg, content, current_chunk, total_chunks } = data.payload ?? {};
|
||||
|
||||
// Input validation
|
||||
if (!batch_id || typeof batch_id !== 'string') {
|
||||
throw error(400, 'Missing or invalid batch_id');
|
||||
}
|
||||
if (!msg || typeof msg !== 'string') {
|
||||
throw error(400, 'Missing or invalid msg');
|
||||
}
|
||||
if (content !== undefined && content !== null && typeof content !== 'string') {
|
||||
throw error(400, 'content must be a string');
|
||||
}
|
||||
|
||||
// Enforce cache size limit — evict oldest batches when over capacity
|
||||
if (!streamCache.has(batch_id) && streamCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = streamCache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
streamCache.delete(oldestKey);
|
||||
logger.warn(`[API] Evicted oldest batch "${oldestKey}" — cache full`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize or update batch
|
||||
if (!streamCache.has(batch_id)) {
|
||||
const cappedTotal = typeof total_chunks === 'number' && total_chunks > 0
|
||||
? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH)
|
||||
: MAX_CHUNKS_PER_BATCH;
|
||||
streamCache.set(batch_id, {
|
||||
chunks: [],
|
||||
status: 'collecting',
|
||||
total_chunks,
|
||||
total_chunks: cappedTotal,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
|
@ -23,27 +53,34 @@ export async function POST({ request }) {
|
|||
|
||||
// Handle different message types
|
||||
if (msg === 'start') {
|
||||
console.log(`[API] Stream started for batch ${batch_id}`);
|
||||
logger.info(`[API] Stream started for batch ${batch_id}`);
|
||||
} else if (msg === 'chunk') {
|
||||
if (batch.chunks.length >= MAX_CHUNKS_PER_BATCH) {
|
||||
logger.warn(`[API] Max chunks reached for batch ${batch_id}, ignoring chunk`);
|
||||
return json({ status: 'received', batch_id, warning: 'max_chunks_reached' });
|
||||
}
|
||||
batch.chunks.push(content);
|
||||
console.log(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
|
||||
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
|
||||
} else if (msg === 'end') {
|
||||
batch.status = 'complete';
|
||||
console.log(`[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`);
|
||||
logger.info(`[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`);
|
||||
} else if (msg === 'error') {
|
||||
batch.status = 'error';
|
||||
batch.error = content;
|
||||
console.log(`[API] Stream error for batch ${batch_id}:`, content);
|
||||
logger.info(`[API] Stream error for batch ${batch_id}:`, content);
|
||||
}
|
||||
|
||||
return json({ status: 'received', batch_id });
|
||||
} catch (error) {
|
||||
console.error('[API] Error processing stream:', error);
|
||||
logger.error('[API] Error processing stream:', error);
|
||||
return json({ status: 'error', message: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export function GET({ url }) {
|
||||
export async function GET({ request, url }: RequestEvent) {
|
||||
// Verify authentication
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const batchId = url.searchParams.get('batch_id');
|
||||
|
||||
// Clean up old cache entries (older than 5 minutes)
|
||||
|
|
|
|||
80
src/routes/api/video-mainpage/+server.ts
Normal file
80
src/routes/api/video-mainpage/+server.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
26
src/routes/api/video-mainpage/list/+server.ts
Normal file
26
src/routes/api/video-mainpage/list/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
56
src/routes/api/video-mainpage/update/+server.ts
Normal file
56
src/routes/api/video-mainpage/update/+server.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
// credentials: 'include'
|
||||
// });
|
||||
//
|
||||
console.log('login success!');
|
||||
logger.info('login success!');
|
||||
|
||||
if (browser && 'cookieStore' in window) await cookieStore.set('logged_in', 'true');
|
||||
else {
|
||||
|
|
@ -70,7 +71,7 @@
|
|||
goto('/entry');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
|
||||
// TODO:
|
||||
authStore.set(null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue