Compare commits
30 commits
feature/ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984865943b | ||
| 96999334f8 | |||
| be0dfb7f63 | |||
| c6b528d5eb | |||
|
|
c02af91353 | ||
|
|
af9a4a2be5 | ||
|
|
1156b5e6c3 | ||
| 67c8363270 | |||
|
|
e2aa2848e8 | ||
| 60d0e40b76 | |||
|
|
1b59c5873c | ||
| 5b1c3a169a | |||
|
|
d0a3b553a5 | ||
|
|
fbbf0c12f4 | ||
|
|
54a5813d06 | ||
|
|
e0b930484a | ||
| 55ec9a29f9 | |||
|
|
13b09e7bb9 | ||
|
|
3cda8aa60b | ||
|
|
aa6414b1cc | ||
|
|
2395cddcdf | ||
|
|
3b6bd1c9f7 | ||
|
|
3625712678 | ||
|
|
7f2548b9a8 | ||
| 7a406fd409 | |||
| 47ee23777d | |||
| 6011b92b7b | |||
| d60a9af6cb | |||
| b5e0705f79 | |||
| 6a2f4e5945 |
59 changed files with 10829 additions and 1765 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=="],
|
||||
|
||||
|
|
|
|||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -65,6 +65,7 @@
|
|||
"@dnd-kit-svelte/svelte": "0.1.3",
|
||||
"@dnd-kit/abstract": "^0.2.4",
|
||||
"@dnd-kit/helpers": "^0.2.4",
|
||||
"@logtape/logtape": "^2.2.2",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
"animejs": "^4.3.6",
|
||||
"firebase": "^12.14.0",
|
||||
"idb": "^8.0.3",
|
||||
"logtape": "https://github.com/dahlia/logtape.git",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"semver": "^7.8.4",
|
||||
"usb": "^2.17.0",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { XIcon } from '@lucide/svelte/icons';
|
||||
import { XIcon, ChevronDown, ChevronRight, FileText } from '@lucide/svelte/icons';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
export interface AnnouncementPayload {
|
||||
interface HistoryItem {
|
||||
filename: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AnnouncementPayload {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
message: string;
|
||||
message: string | HistoryItem[];
|
||||
buttonText?: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
callbackAccept?: () => void;
|
||||
callbackReject?: () => void;
|
||||
}
|
||||
|
||||
let visible = $state(false);
|
||||
let payload = $state<AnnouncementPayload | null>(null);
|
||||
let animating = $state(false);
|
||||
let expandedFilenames = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleItem(filename: string) {
|
||||
const next = new Set(expandedFilenames);
|
||||
if (next.has(filename)) {
|
||||
next.delete(filename);
|
||||
} else {
|
||||
next.add(filename);
|
||||
}
|
||||
expandedFilenames = next;
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedFilenames = new Set();
|
||||
}
|
||||
|
||||
function show(p: AnnouncementPayload) {
|
||||
payload = p;
|
||||
expandedFilenames = new Set();
|
||||
visible = true;
|
||||
// Trigger enter animation on next frame
|
||||
requestAnimationFrame(() => {
|
||||
animating = true;
|
||||
});
|
||||
|
|
@ -26,17 +49,17 @@
|
|||
|
||||
function dismiss() {
|
||||
animating = false;
|
||||
// Wait for exit animation
|
||||
setTimeout(() => {
|
||||
visible = false;
|
||||
payload = null;
|
||||
expandedFilenames = new Set();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && visible) {
|
||||
dismiss();
|
||||
}
|
||||
function formattedItemCount(): string {
|
||||
if (!payload?.message || typeof payload.message === 'string') return '';
|
||||
const n = payload.message.length;
|
||||
return `${n} file${n !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
|
@ -45,12 +68,10 @@
|
|||
unsubscribe = GlobalEventBus.on('announce', (data: AnnouncementPayload) => {
|
||||
show(data);
|
||||
});
|
||||
// if (window) window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
// if (window) window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
const typeStyles: Record<string, { border: string; badge: string; icon: string }> = {
|
||||
|
|
@ -90,7 +111,6 @@
|
|||
aria-labelledby="announcement-title"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
// Close on backdrop click
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
|
|
@ -103,20 +123,21 @@
|
|||
|
||||
<!-- 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="relative flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl transition-all duration-200 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
class:opacity-100={animating}
|
||||
class:opacity-0={!animating}
|
||||
class:scale-100={animating}
|
||||
class:scale-95={!animating}
|
||||
>
|
||||
<!-- Colored top border accent -->
|
||||
<div class="h-1.5 w-full {currentStyles().border}"></div>
|
||||
<div class="h-1.5 w-full shrink-0 {currentStyles().border}"></div>
|
||||
|
||||
<div class="p-6 sm:p-8">
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 sm:p-8">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="absolute top-4 right-4 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
class="absolute top-4 right-4 z-10 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
aria-label="Close announcement"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
|
|
@ -125,7 +146,7 @@
|
|||
<!-- Type badge -->
|
||||
{#if payload?.type}
|
||||
<span
|
||||
class="mb-4 inline-block rounded-full px-3 py-1 text-xs font-semibold tracking-wider uppercase {currentStyles()
|
||||
class="mb-4 inline-block shrink-0 rounded-full px-3 py-1 text-xs font-semibold tracking-wider uppercase {currentStyles()
|
||||
.badge}"
|
||||
>
|
||||
{payload.type}
|
||||
|
|
@ -136,7 +157,7 @@
|
|||
{#if payload?.title}
|
||||
<h2
|
||||
id="announcement-title"
|
||||
class="pr-8 text-2xl font-bold text-neutral-900 dark:text-white"
|
||||
class="shrink-0 pr-8 text-2xl font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
{payload.title}
|
||||
</h2>
|
||||
|
|
@ -144,29 +165,88 @@
|
|||
|
||||
<!-- Subtitle -->
|
||||
{#if payload?.subtitle}
|
||||
<p class="mt-1 text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
<p class="mt-1 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{payload.subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Message body -->
|
||||
{#if payload?.message}
|
||||
{@const isStructured = Array.isArray(payload.message)}
|
||||
|
||||
{#if isStructured}
|
||||
{@const items = payload.message as HistoryItem[]}
|
||||
<!-- File count + collapse-all -->
|
||||
<div class="mt-4 mb-2 flex items-center justify-between shrink-0">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-500">
|
||||
{formattedItemCount()}
|
||||
</span>
|
||||
{#if expandedFilenames.size > 0}
|
||||
<button
|
||||
onclick={collapseAll}
|
||||
class="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Collapse all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable item list -->
|
||||
<div class="flex-1 space-y-1 overflow-y-auto pr-1 min-h-0">
|
||||
{#each items as item (item.filename)}
|
||||
{@const expanded = expandedFilenames.has(item.filename)}
|
||||
<div
|
||||
class="rounded-lg border transition-colors dark:border-neutral-700 {expanded
|
||||
? 'border-neutral-300 dark:border-neutral-600'
|
||||
: 'hover:border-neutral-200 dark:hover:border-neutral-600'}"
|
||||
>
|
||||
<!-- Collapsed header — click to expand -->
|
||||
<button
|
||||
onclick={() => toggleItem(item.filename)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium text-neutral-800 hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 dark:text-neutral-200 dark:hover:bg-neutral-800/50"
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronDown class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
{/if}
|
||||
<FileText class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<span class="truncate font-mono text-xs">{item.filename}</span>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
{#if expanded}
|
||||
<div class="border-t border-neutral-100 px-3 py-3 dark:border-neutral-700">
|
||||
<pre
|
||||
class="max-h-48 overflow-y-auto whitespace-pre-wrap break-words rounded bg-neutral-50 p-3 text-xs leading-relaxed text-neutral-700 dark:bg-neutral-800/60 dark:text-neutral-300"
|
||||
>{item.message}</pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Plain string message -->
|
||||
<div
|
||||
class="mt-4 text-base leading-relaxed whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{payload.message}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Acknowledge / action button -->
|
||||
<!-- Action buttons -->
|
||||
{#if payload?.buttonText !== undefined}
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="inline-flex items-center justify-center rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-neutral-800 focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:outline-none dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
<div class="mt-6 flex shrink-0 justify-end gap-3">
|
||||
{#if payload?.callbackReject}
|
||||
<Button variant="reject" onclick={() => { payload!.callbackReject!(); dismiss(); }}>
|
||||
Reject
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="accept" onclick={() => { payload?.callbackAccept?.(); dismiss(); }}>
|
||||
{payload.buttonText || 'Acknowledge'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
69
src/lib/components/adb-reconnect-banner.svelte
Normal file
69
src/lib/components/adb-reconnect-banner.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import { adbReconnect } from '$lib/core/stores/adbReconnectStore';
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let pending = $derived($adbReconnect);
|
||||
|
||||
/**
|
||||
* Determine which Android server channel to reconnect based on
|
||||
* the current route — same logic as the authed layout's
|
||||
* getAutoConnectChannel().
|
||||
*/
|
||||
function getReconnectChannel(pathname: string): 'brew' | 'recipe' | 'adb' {
|
||||
if (pathname.startsWith('/tools/create-menu')) return 'recipe';
|
||||
if (pathname.startsWith('/tools/brew')) return 'brew';
|
||||
return 'adb';
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
const channel = getReconnectChannel($page.url.pathname);
|
||||
if (channel === 'recipe') {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
} else {
|
||||
await adb.reconnectAndroidServer();
|
||||
}
|
||||
adbReconnect.confirmReconnect();
|
||||
addNotification('INFO:Android socket reconnected');
|
||||
} catch (e) {
|
||||
addNotification('ERR:Failed to reconnect Android socket');
|
||||
// Keep the banner visible so the user can retry
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
adbReconnect.dismissReconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if pending}
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t border-amber-500/30 bg-amber-950/95 px-4 py-3 backdrop-blur-sm"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-amber-400"></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-100">
|
||||
{pending.reason}
|
||||
</p>
|
||||
<p class="text-xs text-amber-300/70">
|
||||
Press Reconnect to restore the connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" onclick={handleDismiss} class="border-amber-600/50 text-amber-200 hover:bg-amber-800/50">
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button size="sm" onclick={handleConfirm} class="bg-amber-600 hover:bg-amber-500">
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
<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';
|
||||
|
|
@ -18,8 +19,10 @@
|
|||
PlusCircle,
|
||||
ImageUp,
|
||||
Video,
|
||||
MonitorPlay,
|
||||
Sun,
|
||||
Moon
|
||||
Moon,
|
||||
ArchiveIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import TaobinLogo from '$lib/assets/logo.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -63,6 +66,12 @@
|
|||
icon: LayoutDashboard,
|
||||
requirePerm: ''
|
||||
}
|
||||
// {
|
||||
// title: 'Firmware Request',
|
||||
// url: '/firmware',
|
||||
// icon: ArchiveIcon,
|
||||
// requirePerm: ''
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -126,6 +135,12 @@
|
|||
url: '/tools/adv-upload',
|
||||
icon: Video,
|
||||
requirePerm: ''
|
||||
},
|
||||
{
|
||||
title: 'Main & Brewing Video',
|
||||
url: '/tools/video-mainpage',
|
||||
icon: MonitorPlay,
|
||||
requirePerm: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -143,6 +158,12 @@
|
|||
url: '/departments',
|
||||
icon: DollarSign,
|
||||
requirePerm: 'document.write.*'
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
url: '/departments',
|
||||
icon: FileSpreadsheet,
|
||||
requirePerm: 'document.write.*'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -236,7 +257,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">import { logger } from '$lib/core/utils/logger';
|
||||
<script lang="ts">
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { Button, type ButtonVariant } from './ui/button';
|
||||
import * as Card from './ui/card/index';
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||
import { file } from 'zod/mini';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
|
||||
let { enableComponent = true } = $props();
|
||||
|
||||
|
|
@ -327,8 +329,15 @@
|
|||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
'ERR:Device is already in use by another program, please close the program and try again'
|
||||
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
|
||||
);
|
||||
GlobalEventBus.emit('announce', {
|
||||
title: 'Device is busy',
|
||||
subtitle: 'Expect `adb` or some related program has been using the device.',
|
||||
message: 'Help: kill the process & try again',
|
||||
buttonText: 'Ok',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
246
src/lib/components/gen-price-panel.svelte
Normal file
246
src/lib/components/gen-price-panel.svelte
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<script lang="ts">
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { addNotification } from '$lib/core/stores/noti.js';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import Progress from '$lib/components/ui/progress/progress.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Cog, Upload, RotateCcw } from '@lucide/svelte/icons';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
import {
|
||||
genLayoutBatch,
|
||||
resetGenLayoutBatch
|
||||
} from '$lib/core/stores/genLayoutStore.js';
|
||||
import { requestGenPrice, requestGenPriceSlot } from '$lib/core/services/sheetService.js';
|
||||
|
||||
// Reusable "Gen Price" / "Gen PriceSlot" panel: triggers gen-service, receives
|
||||
// the streamed profile JSON via the shared genLayoutBatch store, pushes the
|
||||
// files to the machine over ADB, then offers an app restart.
|
||||
let { country, mode }: { country: string; mode: 'price' | 'priceslot' } = $props();
|
||||
|
||||
const sourceDir = '/sdcard/coffeevending';
|
||||
const label = mode === 'price' ? 'Gen Price' : 'Gen PriceSlot';
|
||||
const expectedRef = mode === 'price' ? 'gen-price' : 'gen-priceslot';
|
||||
|
||||
let status = $derived($genLayoutBatch.status);
|
||||
let files = $derived($genLayoutBatch.files);
|
||||
// Only reflect progress for our own kind (the store is shared across pages).
|
||||
let isOurs = $derived($genLayoutBatch.ref === expectedRef);
|
||||
let busy = $derived(isOurs && (status === 'generating' || status === 'receiving'));
|
||||
|
||||
let pushing = $state(false);
|
||||
let pushProgress = $state({ current: 0, total: 0 });
|
||||
let restartOpen = $state(false);
|
||||
let restarting = $state(false);
|
||||
|
||||
function machineConnected(): boolean {
|
||||
return Boolean(adb.getAdbInstance());
|
||||
}
|
||||
|
||||
function startGen() {
|
||||
if (!machineConnected()) {
|
||||
addNotification('ERR:Machine not connected. Cannot push files to Android.');
|
||||
return;
|
||||
}
|
||||
resetGenLayoutBatch();
|
||||
const sent = mode === 'price' ? requestGenPrice(country) : requestGenPriceSlot(country);
|
||||
if (!sent) {
|
||||
addNotification('ERR:WebSocket not connected');
|
||||
}
|
||||
}
|
||||
|
||||
// Web-gen produces "_web" profile variants in the recipe repo (so the canonical
|
||||
// master is never overwritten). Map them to the machine's ACTIVE profile name:
|
||||
// - drop the "_web" marker, and
|
||||
// - for master files replace "master" -> "1" and drop any leading dot.
|
||||
// profile_master_web.json -> /sdcard/coffeevending/profile/profile_1.json
|
||||
// profile_AUS_master_web.json -> /sdcard/coffeevending/profile/profile_AUS_1.json
|
||||
// price/profile_<T>_slot_<N>_web.json -> .../profile/price/profile_<T>_slot_<N>.json
|
||||
// Slots are detected by a "/price/" segment.
|
||||
function toMachinePath(genFilePath: string): string {
|
||||
const isSlot = genFilePath.includes('/price/');
|
||||
let filename = (genFilePath.split('/').pop() || '').replace('_web', '');
|
||||
if (!isSlot) {
|
||||
filename = filename.replace('master', '1');
|
||||
if (filename.startsWith('.')) filename = filename.slice(1);
|
||||
}
|
||||
return isSlot
|
||||
? `${sourceDir}/profile/price/${filename}`
|
||||
: `${sourceDir}/profile/${filename}`;
|
||||
}
|
||||
|
||||
async function pushAll() {
|
||||
if (!machineConnected()) {
|
||||
addNotification('ERR:Machine not connected');
|
||||
return;
|
||||
}
|
||||
const list = $genLayoutBatch.files;
|
||||
if (list.length === 0) {
|
||||
addNotification('WARN:No files to push');
|
||||
return;
|
||||
}
|
||||
|
||||
pushing = true;
|
||||
pushProgress = { current: 0, total: list.length };
|
||||
try {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const file = list[i];
|
||||
const androidPath = toMachinePath(file.file);
|
||||
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||
await adb.push(androidPath, file.content);
|
||||
pushProgress = { current: i + 1, total: list.length };
|
||||
logger.info(`[${label}] Pushed ${i + 1}/${list.length}: ${androidPath}`);
|
||||
}
|
||||
addNotification(`INFO:Pushed ${list.length} ${mode} file(s) to Android`);
|
||||
restartOpen = true;
|
||||
} catch (error) {
|
||||
logger.error(`[${label}] Push error:`, error);
|
||||
addNotification(`ERR:Failed to push files: ${error}`);
|
||||
} finally {
|
||||
pushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestart() {
|
||||
if (!machineConnected()) {
|
||||
addNotification('ERR:Machine not connected');
|
||||
return;
|
||||
}
|
||||
restarting = true;
|
||||
try {
|
||||
// A price change only takes effect after a FULL device reboot (restarting
|
||||
// just the XMLEngine app is not enough for new profile prices).
|
||||
addNotification('INFO:Rebooting machine to apply new prices...');
|
||||
await adb.executeCmd('reboot');
|
||||
addNotification('INFO:Reboot command sent');
|
||||
restartOpen = false;
|
||||
} catch (error) {
|
||||
logger.error(`[${label}] Reboot error:`, error);
|
||||
addNotification(`ERR:Failed to reboot machine: ${error}`);
|
||||
} finally {
|
||||
restarting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border bg-muted/30 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="font-semibold">{label}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Generate and push {mode === 'price' ? 'price' : 'price-slot'} profile files to Machine
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={startGen} disabled={busy || pushing}>
|
||||
{#if busy}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{status === 'generating' ? 'Generating...' : 'Receiving...'}
|
||||
{:else}
|
||||
<Cog class="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if isOurs && status === 'complete' && files.length > 0}
|
||||
<Button variant="default" onclick={pushAll} disabled={pushing || !machineConnected()}>
|
||||
{#if pushing}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Pushing {pushProgress.current}/{pushProgress.total}...
|
||||
{:else}
|
||||
<Upload class="mr-2 h-4 w-4" />
|
||||
Push All ({files.length} files)
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (restartOpen = true)}
|
||||
disabled={pushing || restarting || !machineConnected()}
|
||||
>
|
||||
{#if restarting}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Rebooting...
|
||||
{:else}
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
Reboot Machine
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOurs && status === 'receiving'}
|
||||
<div class="mt-4 space-y-2">
|
||||
<Progress
|
||||
value={$genLayoutBatch.total_files > 0
|
||||
? (files.length / $genLayoutBatch.total_files) * 100
|
||||
: 0}
|
||||
max={100}
|
||||
class="h-2"
|
||||
/>
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
Receiving files: {files.length} / {$genLayoutBatch.total_files}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pushing}
|
||||
<div class="mt-4 space-y-2">
|
||||
<Progress
|
||||
value={pushProgress.total > 0 ? (pushProgress.current / pushProgress.total) * 100 : 0}
|
||||
max={100}
|
||||
class="h-2"
|
||||
/>
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
Pushing to Android: {pushProgress.current} / {pushProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isOurs && status === 'complete' && files.length > 0}
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-sm font-medium">Generated {files.length} file(s):</p>
|
||||
<div class="max-h-32 overflow-y-auto rounded border bg-background p-2">
|
||||
{#each files as file}
|
||||
<div class="truncate font-mono text-xs text-muted-foreground">
|
||||
{file.file.split('/').pop()}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isOurs && status === 'error'}
|
||||
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-600">{$genLayoutBatch.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={restartOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Reboot Machine?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
New {mode} prices only take effect after a full device reboot. The machine
|
||||
will restart — this takes a few minutes.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={() => (restartOpen = false)} disabled={restarting}>
|
||||
Later
|
||||
</Button>
|
||||
<Button onclick={confirmRestart} disabled={restarting}>
|
||||
{#if restarting}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{:else}
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Reboot Now
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
import { sendCommand, sendReset } from '$lib/core/brew/command';
|
||||
import { sendCommandRequest } from '$lib/core/handlers/ws_messageSender';
|
||||
import { needPermission } from '$lib/core/handlers/permissionHandler';
|
||||
import { isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
|
||||
import { isAdbWriterAlive } from '$lib/core/stores/adbWriter';
|
||||
import { sendToAndroid } from '$lib/core/stores/adbWriter';
|
||||
import { departmentStore } from '$lib/core/stores/departments';
|
||||
|
||||
|
|
@ -169,7 +169,7 @@
|
|||
//
|
||||
let inst = adb.getAdbInstance();
|
||||
if (inst) {
|
||||
logger.info('check adb writer', isAdbWriterAvailable());
|
||||
logger.info('check adb writer', isAdbWriterAlive());
|
||||
recipeDetailDispatch('brewNow');
|
||||
} else {
|
||||
logger.info('result check fail');
|
||||
|
|
|
|||
847
src/lib/components/scrcpy-dialog.svelte
Normal file
847
src/lib/components/scrcpy-dialog.svelte
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
<script lang="ts">
|
||||
import { AndroidMotionEventAction, AndroidKeyEventAction } from '@yume-chan/scrcpy';
|
||||
import type { ScrcpyControlMessageWriter, AndroidKeyCode } from '@yume-chan/scrcpy';
|
||||
import { ScrcpyVideoCodecNameMap } from '@yume-chan/scrcpy';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { scrcpyManager, type ScrcpyStatusStep } from '$lib/core/scrcpy/scrcpy-manager';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { cn } from '$lib/utils';
|
||||
import { XIcon } from '@lucide/svelte/icons';
|
||||
|
||||
// ── Props & State ───────────────────────────────────────────────────────
|
||||
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Connection step animation
|
||||
const STEPS: { key: ScrcpyStatusStep; label: string }[] = [
|
||||
{ key: 'fetching', label: 'Downloading scrcpy-server' },
|
||||
{ key: 'pushing', label: 'Pushing to device' },
|
||||
{ key: 'starting', label: 'Starting session' },
|
||||
{ key: 'ready', label: 'Connected' }
|
||||
];
|
||||
let currentStep = $state<ScrcpyStatusStep | ''>('');
|
||||
let stepDetail = $state('');
|
||||
let completedSteps = $state<Set<ScrcpyStatusStep>>(new Set());
|
||||
|
||||
// Derived step states — recomputes reactively when currentStep/completedSteps change
|
||||
let stepStates = $derived(
|
||||
STEPS.map((step) => ({
|
||||
...step,
|
||||
isCompleted: completedSteps.has(step.key),
|
||||
isActive: currentStep === step.key
|
||||
}))
|
||||
);
|
||||
|
||||
// Status log
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
time: string;
|
||||
type: 'info' | 'warn' | 'error' | 'ok';
|
||||
message: string;
|
||||
}
|
||||
let logEntries = $state<LogEntry[]>([]);
|
||||
let logId = 0;
|
||||
|
||||
function log(type: LogEntry['type'], message: string) {
|
||||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
logEntries = [...logEntries, { id: ++logId, time, type, message }];
|
||||
// Keep last 80 entries
|
||||
if (logEntries.length > 80) logEntries = logEntries.slice(-80);
|
||||
logger.info(`[ScrcpyDialog] [${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
// ── Decoder state ───────────────────────────────────────────────────────
|
||||
let decoder: VideoDecoder | null = null;
|
||||
let controlWriter: ScrcpyControlMessageWriter | null = null;
|
||||
let lastFrame: VideoFrame | null = null;
|
||||
let rendering = false;
|
||||
let rendererHandle = 0;
|
||||
let frameCount = 0;
|
||||
let closed = false;
|
||||
let stopping = false;
|
||||
let starting = false;
|
||||
let sessionStarted = false;
|
||||
|
||||
// Touch state
|
||||
let pointerId = -1;
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
let videoWidth = 0;
|
||||
let videoHeight = 0;
|
||||
let activeKeyAction: 'down' | 'up' | null = null;
|
||||
let touchLocked = $state(true);
|
||||
|
||||
// Session metadata
|
||||
let sessionCodec: string = '';
|
||||
let sessionSize: string = '';
|
||||
|
||||
// ── H.264 NALU parsing ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Parse NALUs from a byte buffer. Handles two formats:
|
||||
* 1. Annex B: NALUs separated by 00 00 00 01 or 00 00 01 start codes
|
||||
* 2. AVCC: 4-byte big-endian length prefix before each NALU
|
||||
*
|
||||
* Auto-detects format by checking if data starts with a start code.
|
||||
*/
|
||||
function parseNalus(data: Uint8Array): Uint8Array[] {
|
||||
// Detect Annex B: starts with 00 00 00 01 or 00 00 01
|
||||
const isAnnexB =
|
||||
(data.length >= 4 && data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 1) ||
|
||||
(data.length >= 3 && data[0] === 0 && data[1] === 0 && data[2] === 1);
|
||||
|
||||
if (isAnnexB) {
|
||||
return parseAnnexBNalus(data);
|
||||
}
|
||||
|
||||
// AVCC: 4-byte length-prefixed NALUs
|
||||
const nalus: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 4 <= data.length) {
|
||||
const naluLen = (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
offset += 4;
|
||||
if (naluLen <= 0 || offset + naluLen > data.length) break;
|
||||
nalus.push(data.slice(offset, offset + naluLen));
|
||||
offset += naluLen;
|
||||
}
|
||||
if (nalus.length > 0) return nalus;
|
||||
|
||||
// Fallback: treat entire data as a single raw NALU (no start codes, no length prefix)
|
||||
return [data];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Annex B byte stream to extract NALUs (without start codes).
|
||||
* Each NALU's type is (firstByte & 0x1F): 7=SPS, 8=PPS.
|
||||
*/
|
||||
function parseAnnexBNalus(data: Uint8Array): Uint8Array[] {
|
||||
const nalus: Uint8Array[] = [];
|
||||
let i = 0;
|
||||
let naluStart = -1;
|
||||
|
||||
while (i < data.length) {
|
||||
// 4-byte start code: 00 00 00 01
|
||||
if (i + 3 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0 && data[i + 3] === 1) {
|
||||
if (naluStart >= 0) nalus.push(data.slice(naluStart, i));
|
||||
naluStart = i + 4;
|
||||
i += 4;
|
||||
}
|
||||
// 3-byte start code: 00 00 01
|
||||
else if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1) {
|
||||
if (naluStart >= 0) nalus.push(data.slice(naluStart, i));
|
||||
naluStart = i + 3;
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (naluStart >= 0 && naluStart < data.length) {
|
||||
nalus.push(data.slice(naluStart));
|
||||
}
|
||||
return nalus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AVDecoderConfigurationRecord (avcC box) from collected
|
||||
* SPS and PPS NALUs (raw NALU data including the header byte).
|
||||
*/
|
||||
function buildAvcCBox(spsNalu: Uint8Array, ppsNalu: Uint8Array): ArrayBuffer {
|
||||
const spsLen = spsNalu.length;
|
||||
const ppsLen = ppsNalu.length;
|
||||
// avcC header (6) + SPS count(1) + SPS length(2) + SPS data + PPS count(1) + PPS length(2) + PPS data
|
||||
const box = new Uint8Array(6 + 1 + 2 + spsLen + 1 + 2 + ppsLen);
|
||||
let off = 0;
|
||||
|
||||
box[off++] = 1; // configurationVersion
|
||||
box[off++] = spsNalu[1]; // AVCProfileIndication (profile_idc)
|
||||
box[off++] = spsNalu[2]; // profile_compatibility
|
||||
box[off++] = spsNalu[3]; // AVCLevelIndication (level_idc)
|
||||
box[off++] = 0xFF; // lengthSizeMinusOne = 3 (4-byte NALU lengths)
|
||||
|
||||
// SPS
|
||||
box[off++] = 0xE1; // numOfSequenceParameterSets = 1
|
||||
box[off++] = (spsLen >> 8) & 0xff;
|
||||
box[off++] = spsLen & 0xff;
|
||||
box.set(spsNalu, off);
|
||||
off += spsLen;
|
||||
|
||||
// PPS
|
||||
box[off++] = 1; // numOfPictureParameterSets = 1
|
||||
box[off++] = (ppsLen >> 8) & 0xff;
|
||||
box[off++] = ppsLen & 0xff;
|
||||
box.set(ppsNalu, off);
|
||||
off += ppsLen;
|
||||
|
||||
const hex = Array.from(box).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('ok', `avcC box built: ${box.length} bytes [${hex}]`);
|
||||
return box.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of configuration packets (from scrcpy stream), parse NALUs
|
||||
* from each packet, find SPS and PPS, and build an avcC box.
|
||||
* Returns { description, codec } or throws.
|
||||
*/
|
||||
function buildDecoderConfig(configPackets: Uint8Array[]): { description: ArrayBuffer; codec: string } {
|
||||
let sps: Uint8Array | null = null;
|
||||
let pps: Uint8Array | null = null;
|
||||
|
||||
for (const packet of configPackets) {
|
||||
// Each config packet may contain one or more NALUs (Annex B or AVCC)
|
||||
const nalus = parseNalus(packet);
|
||||
log('info', `Config packet: ${packet.length} bytes → ${nalus.length} NALUs`);
|
||||
for (const nalu of nalus) {
|
||||
if (nalu.length === 0) continue;
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
if (naluType === 7) {
|
||||
sps = nalu;
|
||||
log('info', ` SPS found: ${nalu.length} bytes, profile=${nalu[1]}, level=${nalu[3]}`);
|
||||
} else if (naluType === 8) {
|
||||
pps = nalu;
|
||||
log('info', ` PPS found: ${nalu.length} bytes`);
|
||||
} else {
|
||||
log('info', ` Config NALU type=${naluType}, ${nalu.length} bytes (ignored)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sps || !pps) {
|
||||
throw new Error(`Missing SPS/PPS in config data`);
|
||||
}
|
||||
|
||||
const profileIdc = sps[1];
|
||||
const compat = sps[2];
|
||||
const levelIdc = sps[3];
|
||||
const codec = `avc1.${profileIdc.toString(16).padStart(2, '0')}${compat.toString(16).padStart(2, '0')}${levelIdc.toString(16).padStart(2, '0')}`;
|
||||
log('info', `Codec string: ${codec}`);
|
||||
|
||||
const description = buildAvcCBox(sps, pps);
|
||||
return { description, codec };
|
||||
}
|
||||
|
||||
// ── Decode loop ─────────────────────────────────────────────────────────
|
||||
async function startDecoding(
|
||||
videoStream: ReadableStream<any>,
|
||||
sWidth: number,
|
||||
sHeight: number
|
||||
) {
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reader = videoStream.getReader();
|
||||
|
||||
log('info', 'Stream reader acquired, waiting for packets…');
|
||||
|
||||
// Collect configuration NALUs from the stream
|
||||
const configNalus: Uint8Array[] = [];
|
||||
let gotConfig = false;
|
||||
let firstDataPacket = true;
|
||||
|
||||
try {
|
||||
while (!closed) {
|
||||
const { done, value: rawPacket } = await reader.read();
|
||||
if (done) {
|
||||
log('warn', 'Stream ended');
|
||||
break;
|
||||
}
|
||||
if (!rawPacket) continue;
|
||||
|
||||
const packet = rawPacket as { type: string; data?: Uint8Array; pts?: bigint; keyframe?: boolean };
|
||||
|
||||
// ── Configuration packets (SPS/PPS) ─────────────────────
|
||||
if (packet.type === 'configuration' && packet.data) {
|
||||
// Log first 48 bytes as hex to diagnose format
|
||||
const hex = Array.from(packet.data.slice(0, 48)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('info', `Config NALU #${configNalus.length}: ${packet.data.length} bytes [${hex}${packet.data.length > 48 ? '…' : ''}]`);
|
||||
configNalus.push(packet.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── First data packet → initialize decoder ──────────────
|
||||
if (packet.type === 'data' && packet.data && !gotConfig) {
|
||||
gotConfig = true;
|
||||
log('ok', `Got ${configNalus.length} config NALUs, first data packet arrived`);
|
||||
|
||||
if (configNalus.length === 0) {
|
||||
log('error', 'No config NALUs received before data — cannot initialize decoder');
|
||||
error = 'No H.264 SPS/PPS received from device';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { description, codec } = buildDecoderConfig(configNalus);
|
||||
sessionCodec = codec;
|
||||
|
||||
decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
lastFrame?.close();
|
||||
lastFrame = frame;
|
||||
if (loading) {
|
||||
loading = false;
|
||||
log('ok', 'First frame rendered');
|
||||
}
|
||||
scheduleRender();
|
||||
},
|
||||
error: (e) => {
|
||||
log('error', `VideoDecoder error: ${e.message} (code=${e.code})`);
|
||||
}
|
||||
});
|
||||
|
||||
const cfg: VideoDecoderConfig = {
|
||||
codec,
|
||||
codedWidth: sWidth || 480,
|
||||
codedHeight: sHeight || 854,
|
||||
description
|
||||
};
|
||||
|
||||
log('info', `Configuring decoder: ${cfg.codedWidth}×${cfg.codedHeight}, codec=${cfg.codec}`);
|
||||
await decoder.configure(cfg);
|
||||
log('ok', 'VideoDecoder configured successfully');
|
||||
sessionSize = `${sWidth || 480}×${sHeight || 854}`;
|
||||
} catch (e) {
|
||||
log('error', `Decoder init failed: ${e instanceof Error ? e.message : e}`);
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data packets → decode ───────────────────────────────
|
||||
if (packet.type === 'data' && packet.data && decoder) {
|
||||
if (firstDataPacket) {
|
||||
firstDataPacket = false;
|
||||
const hex = Array.from(packet.data.slice(0, 32)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('info', `First data packet: ${packet.data.length} bytes, keyframe=${packet.keyframe}, pts=${packet.pts} [${hex}…]`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert Annex B → AVCC if needed (WebCodecs expects AVCC with avcC description)
|
||||
const raw = packet.data;
|
||||
const isAnnexB = raw.length >= 4 && raw[0] === 0 && raw[1] === 0 && raw[2] === 0 && raw[3] === 1;
|
||||
const chunkData = isAnnexB ? annexBToAvcc(raw) : raw;
|
||||
|
||||
decoder.decode(
|
||||
new EncodedVideoChunk({
|
||||
type: packet.keyframe ? 'key' : 'delta',
|
||||
timestamp: Number(packet.pts ?? 0n),
|
||||
duration: 0,
|
||||
data: chunkData
|
||||
})
|
||||
);
|
||||
frameCount++;
|
||||
} catch (e) {
|
||||
if (frameCount === 0) {
|
||||
log('error', `First frame decode failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('error', `Stream read error: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
if (rendering) return;
|
||||
rendering = true;
|
||||
rendererHandle = requestAnimationFrame(() => {
|
||||
rendering = false;
|
||||
if (!lastFrame || !ctx || !canvas) return;
|
||||
// Resize canvas buffer to match frame dimensions
|
||||
if (canvas.width !== lastFrame.displayWidth || canvas.height !== lastFrame.displayHeight) {
|
||||
log('info', `Canvas resize: ${canvas.width}×${canvas.height} → ${lastFrame.displayWidth}×${lastFrame.displayHeight}`);
|
||||
canvas.width = lastFrame.displayWidth;
|
||||
canvas.height = lastFrame.displayHeight;
|
||||
}
|
||||
ctx.drawImage(lastFrame, 0, 0);
|
||||
lastFrame.close();
|
||||
lastFrame = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert H.264 Annex B data (start-code prefixed) to AVCC format
|
||||
* (4-byte big-endian length prefixed). WebCodecs with avcC description
|
||||
* expects AVCC format in EncodedVideoChunk.
|
||||
*/
|
||||
function annexBToAvcc(data: Uint8Array): Uint8Array {
|
||||
const nalus = parseAnnexBNalus(data);
|
||||
if (nalus.length === 0) return data; // already AVCC or empty
|
||||
|
||||
// Calculate total size: 4 bytes length prefix per NALU
|
||||
let totalSize = 0;
|
||||
for (const nalu of nalus) totalSize += 4 + nalu.length;
|
||||
|
||||
const result = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const nalu of nalus) {
|
||||
const len = nalu.length;
|
||||
result[offset++] = (len >>> 24) & 0xff;
|
||||
result[offset++] = (len >>> 16) & 0xff;
|
||||
result[offset++] = (len >>> 8) & 0xff;
|
||||
result[offset++] = len & 0xff;
|
||||
result.set(nalu, offset);
|
||||
offset += len;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Touch helpers ────────────────────────────────────────────────────────
|
||||
function pointerToVideo(e: PointerEvent) {
|
||||
if (!canvas || !videoWidth || !videoHeight) return { x: 0, y: 0 };
|
||||
const r = canvas.getBoundingClientRect();
|
||||
|
||||
// Account for object-contain: the rendered content is centered within the
|
||||
// bounding rect with potential letterbox/pillarbox margins.
|
||||
const containerRatio = r.width / r.height;
|
||||
const contentRatio = videoWidth / videoHeight;
|
||||
let contentW: number, contentH: number, offsetX: number, offsetY: number;
|
||||
if (containerRatio > contentRatio) {
|
||||
// Container wider → height-constrained → horizontal letterbox
|
||||
contentH = r.height;
|
||||
contentW = r.height * contentRatio;
|
||||
offsetX = (r.width - contentW) / 2;
|
||||
offsetY = 0;
|
||||
} else {
|
||||
// Container taller → width-constrained → vertical pillarbox
|
||||
contentW = r.width;
|
||||
contentH = r.width / contentRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (r.height - contentH) / 2;
|
||||
}
|
||||
|
||||
const x = Math.round(((e.clientX - r.left - offsetX) / contentW) * videoWidth);
|
||||
const y = Math.round(((e.clientY - r.top - offsetY) / contentH) * videoHeight);
|
||||
return {
|
||||
x: Math.max(0, Math.min(videoWidth, x)),
|
||||
y: Math.max(0, Math.min(videoHeight, y))
|
||||
};
|
||||
}
|
||||
|
||||
function sendTouch(action: AndroidMotionEventAction, e: PointerEvent) {
|
||||
if (touchLocked || !controlWriter || !videoWidth || !videoHeight) return;
|
||||
const { x, y } = pointerToVideo(e);
|
||||
controlWriter.injectTouch({
|
||||
action,
|
||||
pointerId: BigInt(pointerId),
|
||||
pointerX: x,
|
||||
pointerY: y,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
pressure: action === AndroidMotionEventAction.Move ? 1 : 0.5,
|
||||
actionButton: 1,
|
||||
buttons: 1
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointerId = e.pointerId;
|
||||
canvas?.setPointerCapture(e.pointerId);
|
||||
sendTouch(AndroidMotionEventAction.Down, e);
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
sendTouch(AndroidMotionEventAction.Up, e);
|
||||
pointerId = -1;
|
||||
canvas?.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (pointerId === -1) return;
|
||||
sendTouch(AndroidMotionEventAction.Move, e);
|
||||
}
|
||||
|
||||
// ── Keyboard helpers ─────────────────────────────────────────────────────
|
||||
const KEY_MAP = {
|
||||
Backspace: 67,
|
||||
Home: 3,
|
||||
'Alt+ArrowUp': 82,
|
||||
'Alt+ArrowDown': 82,
|
||||
'Alt+ArrowLeft': 88,
|
||||
'Alt+ArrowRight': 89
|
||||
} as const;
|
||||
|
||||
function onKeyActionStart(code: string, combo: string) {
|
||||
if (!controlWriter || activeKeyAction) return;
|
||||
const mapped = (combo in KEY_MAP) ? KEY_MAP[combo as keyof typeof KEY_MAP] : undefined;
|
||||
if (mapped === undefined) return;
|
||||
activeKeyAction = 'down';
|
||||
controlWriter.injectKeyCode({
|
||||
keyCode: mapped as AndroidKeyCode,
|
||||
action: AndroidKeyEventAction.Down,
|
||||
repeat: 0,
|
||||
metaState: 0
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyActionEnd() {
|
||||
if (!controlWriter || activeKeyAction !== 'down') return;
|
||||
controlWriter.injectKeyCode({
|
||||
keyCode: 67 as AndroidKeyCode,
|
||||
action: AndroidKeyEventAction.Up,
|
||||
repeat: 0,
|
||||
metaState: 0
|
||||
});
|
||||
activeKeyAction = null;
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const combo = `${e.altKey ? 'Alt+' : ''}${e.code}`;
|
||||
if (e.type === 'keydown' && !e.repeat) onKeyActionStart(e.code, combo);
|
||||
if (e.type === 'keyup') onKeyActionEnd();
|
||||
}
|
||||
|
||||
// ── Session lifecycle ───────────────────────────────────────────────────
|
||||
async function startSession() {
|
||||
if (starting) return;
|
||||
starting = true;
|
||||
loading = true;
|
||||
error = '';
|
||||
logEntries = [];
|
||||
logId = 0;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
log('info', 'Starting scrcpy session…');
|
||||
|
||||
try {
|
||||
const client = await scrcpyManager.start(
|
||||
{
|
||||
maxSize: 1024,
|
||||
videoBitRate: 8_000_000,
|
||||
control: true
|
||||
},
|
||||
(step, detail) => {
|
||||
// Mark previous step as completed
|
||||
if (currentStep && currentStep !== 'error' && currentStep !== 'ready') {
|
||||
completedSteps = new Set([...completedSteps, currentStep]);
|
||||
}
|
||||
currentStep = step;
|
||||
stepDetail = detail ?? '';
|
||||
if (detail) log(step === 'error' ? 'error' : 'info', detail);
|
||||
}
|
||||
);
|
||||
|
||||
log('ok', 'Scrcpy session started');
|
||||
|
||||
// Access the video stream — this must be the first read of the stream
|
||||
const videoStream = await client.videoStream;
|
||||
if (!videoStream) {
|
||||
throw new Error('videoStream is null');
|
||||
}
|
||||
|
||||
const { metadata } = videoStream;
|
||||
videoWidth = metadata.width ?? 0;
|
||||
videoHeight = metadata.height ?? 0;
|
||||
sessionCodec = ScrcpyVideoCodecNameMap.get(metadata.codec) ?? `codec=${metadata.codec}`;
|
||||
sessionSize = `${videoWidth}×${videoHeight}`;
|
||||
log('info', `Video metadata: ${sessionSize}, codec=${sessionCodec}`);
|
||||
|
||||
controlWriter = client.controller ?? null;
|
||||
log('info', `Control: ${controlWriter ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// Set canvas to reported size (or fallback)
|
||||
if (canvas) {
|
||||
canvas.width = videoWidth || 480;
|
||||
canvas.height = videoHeight || 854;
|
||||
}
|
||||
|
||||
// Start decoding — this reads from the stream
|
||||
log('info', 'Starting decode loop…');
|
||||
await startDecoding(videoStream.stream as ReadableStream<any>, videoWidth, videoHeight);
|
||||
|
||||
log('info', 'Session ended');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log('error', msg);
|
||||
error = msg;
|
||||
currentStep = 'error';
|
||||
} finally {
|
||||
loading = false;
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSession() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
log('info', 'Stopping session…');
|
||||
closed = true;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
|
||||
cancelAnimationFrame(rendererHandle);
|
||||
rendering = false;
|
||||
|
||||
if (decoder) {
|
||||
try { await decoder.flush(); } catch { /* */ }
|
||||
decoder.close();
|
||||
decoder = null;
|
||||
}
|
||||
lastFrame?.close();
|
||||
lastFrame = null;
|
||||
|
||||
controlWriter = null;
|
||||
videoWidth = 0;
|
||||
videoHeight = 0;
|
||||
frameCount = 0;
|
||||
|
||||
await scrcpyManager.close();
|
||||
log('ok', 'Session closed');
|
||||
stopping = false;
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
log('info', 'Reconnecting…');
|
||||
// Force-reset decoder state
|
||||
if (decoder) {
|
||||
try { await decoder.flush(); } catch { /* */ }
|
||||
decoder.close();
|
||||
decoder = null;
|
||||
}
|
||||
lastFrame?.close();
|
||||
lastFrame = null;
|
||||
cancelAnimationFrame(rendererHandle);
|
||||
rendering = false;
|
||||
controlWriter = null;
|
||||
touchLocked = true;
|
||||
// Force-close any lingering scrcpy session
|
||||
if (scrcpyManager.isActive) {
|
||||
await scrcpyManager.close();
|
||||
}
|
||||
// Reset all flags
|
||||
closed = false;
|
||||
stopping = false;
|
||||
starting = false;
|
||||
loading = false;
|
||||
frameCount = 0;
|
||||
videoWidth = 0;
|
||||
videoHeight = 0;
|
||||
error = '';
|
||||
sessionCodec = '';
|
||||
sessionSize = '';
|
||||
logEntries = [];
|
||||
logId = 0;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
// Start fresh
|
||||
sessionStarted = false;
|
||||
startSession();
|
||||
}
|
||||
|
||||
// ── Open / close binding ─────────────────────────────────────────────────
|
||||
// sessionStarted prevents re-entry: startSession() mutates $state which
|
||||
// re-triggers this effect body — without the guard it would loop forever.
|
||||
$effect(() => {
|
||||
if (open && !sessionStarted) {
|
||||
sessionStarted = true;
|
||||
// Force-reset all state so startSession() doesn't bail early
|
||||
// (previous stopSession() may still be running async from cleanup)
|
||||
closed = false;
|
||||
stopping = false;
|
||||
starting = false;
|
||||
loading = false;
|
||||
frameCount = 0;
|
||||
error = '';
|
||||
touchLocked = true;
|
||||
startSession();
|
||||
}
|
||||
// Cleanup runs when open transitions true→false (dialog closes)
|
||||
return () => {
|
||||
if (scrcpyManager.isActive && !stopping) {
|
||||
stopSession();
|
||||
}
|
||||
// Reset guard so next open triggers a fresh session
|
||||
if (!open) {
|
||||
sessionStarted = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} onkeyup={handleKey} />
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="bg-black/60 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
class="max-h-[90vh] w-[95vw] max-w-[960px] overflow-hidden p-0 sm:max-w-[1080px]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<!-- ── Header ─────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<h2 class="text-sm font-semibold truncate">Screen Mirror</h2>
|
||||
{#if sessionCodec}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sessionCodec}
|
||||
</span>
|
||||
{/if}
|
||||
{#if sessionSize}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sessionSize}
|
||||
</span>
|
||||
{/if}
|
||||
{#if frameCount > 0}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-emerald-100 dark:bg-emerald-900/40 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{frameCount} frames
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<!-- ── Canvas ─────────────────────────────────────────── -->
|
||||
<div class="relative flex items-center justify-center bg-black min-h-[300px]">
|
||||
{#if loading && frameCount === 0 && !error}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6">
|
||||
{#each stepStates as step (step.key)}
|
||||
<div class={cn(
|
||||
'flex items-center gap-3 text-xs transition-all duration-300',
|
||||
step.isCompleted ? 'text-emerald-400' : step.isActive ? 'text-foreground' : 'text-muted-foreground/40'
|
||||
)}>
|
||||
{#if step.isCompleted}
|
||||
<div class="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<svg class="size-3 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
{:else if step.isActive}
|
||||
<div class="size-5 shrink-0 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{:else}
|
||||
<div class="size-5 shrink-0 rounded-full border border-muted-foreground/30"></div>
|
||||
{/if}
|
||||
<span class="font-medium">{step.label}</span>
|
||||
{#if step.isActive && stepDetail}
|
||||
<span class="text-muted-foreground/60">— {stepDetail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<div class="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-center">
|
||||
<p class="text-xs font-medium text-destructive">{error}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={reconnect}>
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current mr-1"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !loading && frameCount === 0 && sessionStarted && !stopping}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Button variant="outline" size="sm" onclick={reconnect}>
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current mr-1"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="max-h-[65vh] w-full object-contain"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointerup={onPointerUp}
|
||||
onpointermove={onPointerMove}
|
||||
role="img"
|
||||
aria-label="Android screen"
|
||||
></canvas>
|
||||
{#if touchLocked && frameCount > 0}
|
||||
<div class="absolute bottom-2 right-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-[10px] text-white/70">
|
||||
<svg viewBox="0 0 24 24" class="size-3 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
Touch locked
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Nav bar ─────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between border-t px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 3, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Home">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 67, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Back">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 187, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Recent Apps">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={touchLocked ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
onclick={() => { touchLocked = !touchLocked; }}
|
||||
>
|
||||
{#if touchLocked}
|
||||
<!-- Hand icon: click to unlock -->
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 11V6a2 2 0 0 0-4 0v1"/>
|
||||
<path d="M14 10V4a2 2 0 0 0-4 0v2"/>
|
||||
<path d="M10 10.5V6a2 2 0 0 0-4 0v8"/>
|
||||
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
|
||||
</svg>
|
||||
<span class="text-xs">Unlock Touch</span>
|
||||
{:else}
|
||||
<!-- Hand with slash: click to lock -->
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 11V6a2 2 0 0 0-4 0v1"/>
|
||||
<path d="M14 10V4a2 2 0 0 0-4 0v2"/>
|
||||
<path d="M10 10.5V6a2 2 0 0 0-4 0v8"/>
|
||||
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
<span class="text-xs">Lock Touch</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- ── Status log ───────────────────────────────────────── -->
|
||||
<div class="border-t bg-muted/30">
|
||||
<div class="max-h-[140px] overflow-y-auto px-3 py-2 font-mono text-[10px] leading-relaxed">
|
||||
{#each logEntries as entry (entry.id)}
|
||||
{@const iconClass = entry.type === 'ok'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: entry.type === 'warn'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-muted-foreground'}
|
||||
{@const msgClass = entry.type === 'ok'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: entry.type === 'warn'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-foreground'}
|
||||
<div class="flex gap-2">
|
||||
<span class="shrink-0 text-muted-foreground/60">{entry.time}</span>
|
||||
<span class={cn('shrink-0 w-8 text-right', iconClass)}>
|
||||
{entry.type === 'ok' ? '✓' : entry.type === 'error' ? '✗' : entry.type === 'warn' ? '!' : '·'}
|
||||
</span>
|
||||
<span class={cn('min-w-0 break-all', msgClass)}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if logEntries.length === 0}
|
||||
<span class="text-muted-foreground/40">Waiting…</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
accept: "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-600 dark:hover:bg-emerald-500",
|
||||
reject: "bg-red-600 text-white shadow-sm hover:bg-red-500 dark:bg-red-600 dark:hover:bg-red-500",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
|
|
|
|||
|
|
@ -13,15 +13,22 @@ import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
|
|||
import { addNotification } from '../stores/noti';
|
||||
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
|
||||
import { GlobalEventBus } from '../utils/eventBus';
|
||||
import { adbWriter } from '../stores/adbWriter';
|
||||
import { adbWriter, sendToAndroid } from '../stores/adbWriter';
|
||||
import { adbReconnect } from '../stores/adbReconnectStore';
|
||||
import { WritableStream } from '@yume-chan/stream-extra';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { get } from 'svelte/store';
|
||||
import { adbConnectionStatus } from '../stores/adbConnectionStore';
|
||||
import { browser } from '$app/environment';
|
||||
import AdbWorker from '$lib/workers/androidPayload.worker?worker';
|
||||
|
||||
let _connectionPromise: Promise<boolean> | null = null;
|
||||
let syncConnection: any = null;
|
||||
|
||||
let worker: Worker | undefined;
|
||||
|
||||
let healthCheckRoutine: any;
|
||||
|
||||
/**
|
||||
* Centralized connection function that all pages and components should use.
|
||||
*
|
||||
|
|
@ -192,7 +199,7 @@ async function connectWithRetry<T>(
|
|||
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
||||
adbConnectionStatus.setConnecting();
|
||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||
logger.info('usb ok', globalThis.navigator.usb);
|
||||
logger.info('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
|
||||
if (device) {
|
||||
logger.info('connect ', device.name);
|
||||
|
||||
|
|
@ -215,11 +222,9 @@ export async function connnectViaWebUSB(connectAndroidServer = true) {
|
|||
// save device info
|
||||
await deviceCredentialManager.saveDeviceInfo(device);
|
||||
} catch (e: any) {
|
||||
logger.error('error on connect', e);
|
||||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
'ERR:Device is already in use by another program, please close the program and try again'
|
||||
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +301,7 @@ export async function connectRecipeMenuViaWebUSB() {
|
|||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
'ERR:Device is already in use by another program, please close the program and try again'
|
||||
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +341,7 @@ export async function connectRecipeMenuDeviceByCred(
|
|||
}
|
||||
|
||||
export async function reconnectAndroidRecipeMenuServer() {
|
||||
adbWriter.set(null);
|
||||
await connectToAndroidRecipeMenuServer(true);
|
||||
}
|
||||
|
||||
|
|
@ -392,8 +398,8 @@ function sanitizeAdbCommand(command: string): string {
|
|||
return command;
|
||||
}
|
||||
|
||||
export async function executeCmd(command: string) {
|
||||
command = sanitizeAdbCommand(command);
|
||||
export async function executeCmd(command: string, block: boolean = false) {
|
||||
command = block ? sanitizeAdbCommand(command) : command;
|
||||
let instance = getAdbInstance();
|
||||
|
||||
if (!instance) {
|
||||
|
|
@ -441,6 +447,15 @@ export async function executeCmd(command: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function goToMachineHome() {
|
||||
if (!getAdbInstance()) return;
|
||||
try {
|
||||
await executeCmd('input keyevent KEYCODE_HOME');
|
||||
} catch (e) {
|
||||
console.error('[goToMachineHome] error', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ADB command and stream its output via callbacks.
|
||||
* Used for commands like `logcat` that run indefinitely.
|
||||
|
|
@ -652,6 +667,9 @@ export async function pushBinary(
|
|||
|
||||
// NOTE: adb reverse is not work by unavailable features support
|
||||
export async function reconnectAndroidServer() {
|
||||
// Clear any stale writer before reconnecting so the new stream
|
||||
// doesn't conflict with a dead one still referenced in the store.
|
||||
adbWriter.set(null);
|
||||
await connectToAndroidServer();
|
||||
}
|
||||
|
||||
|
|
@ -678,13 +696,28 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
// logger.info('checking on writer ', writer);
|
||||
if (browser) {
|
||||
worker = new AdbWorker();
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data.type === 'DATA') {
|
||||
const raw = e.data.payload;
|
||||
handleAdbPayload(new TextDecoder().decode(raw));
|
||||
}
|
||||
};
|
||||
}
|
||||
adbWriter.set(writer);
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Brewing Mode T on machine');
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
healthCheckRoutine = setInterval(() => {
|
||||
sendToAndroid({ type: 'status', payload: {} });
|
||||
}, 1000);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
|
@ -692,33 +725,23 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// decode chunk
|
||||
buffer += textDecoder.decode(value, { stream: true });
|
||||
|
||||
let lines = buffer.split('\n');
|
||||
|
||||
// save potential incomplete
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
GlobalEventBus.emit('adb:raw-payload', line);
|
||||
handleAdbPayload(line);
|
||||
}
|
||||
|
||||
// GlobalEventBus.emit('adb:raw-payload', new TextDecoder().decode(value));
|
||||
// handleAdbPayload(new TextDecoder().decode(value));
|
||||
// Transfer ONLY the buffer, not the stream
|
||||
// Type assertion (as any) fixes the overload error
|
||||
(worker as any).postMessage({ type: 'CHUNK', payload: value }, [value.buffer]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('read error', e);
|
||||
if (isRecoverableError(e)) {
|
||||
void connectToAndroidServer();
|
||||
}
|
||||
logger.error('Android server read error', e);
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
addNotification('WARN:Brewing Mode T Offline ...');
|
||||
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
addNotification('WARN:Android server channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
// Prompt the user to reconnect instead of auto-reconnecting
|
||||
adbReconnect.requestReconnect('Android server disconnected');
|
||||
}
|
||||
})();
|
||||
return;
|
||||
|
|
@ -788,6 +811,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
adbWriter.set(writer);
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Android recipe menu channel');
|
||||
healthCheckRoutine = setInterval(() => {
|
||||
sendToAndroid({ type: 'status', payload: {} });
|
||||
}, 1000);
|
||||
} else {
|
||||
addNotification('WARN:Android recipe menu channel unavailable');
|
||||
|
||||
|
|
@ -830,20 +856,23 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
logger.error('recipe menu read error', e);
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
addNotification('WARN:Android recipe menu channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
if (retryOnFailure) {
|
||||
scheduleRecipeMenuAndroidServerReconnect();
|
||||
}
|
||||
// Prompt the user to reconnect instead of auto-scheduling
|
||||
adbReconnect.requestReconnect('Android recipe menu disconnected');
|
||||
}
|
||||
})();
|
||||
} catch (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) {
|
||||
scheduleRecipeMenuAndroidServerReconnect();
|
||||
}
|
||||
// Prompt the user to reconnect instead of auto-scheduling
|
||||
adbReconnect.requestReconnect('Android recipe menu connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ type AdbPayload = { type: string; payload: any };
|
|||
|
||||
let queuedPromises = new Array<Promise<void>>();
|
||||
|
||||
// 10 secs
|
||||
const SLOW_PAYLOAD_RESPONSE_MS = 10000;
|
||||
|
||||
async function handleAdbPayload(raw_payload: string) {
|
||||
// logger.info('[ADB] Received payload:', raw_payload.slice(0, 300));
|
||||
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||
|
|
@ -156,6 +159,22 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
// show error to user from brew app
|
||||
addNotification(`ERR:${payload.payload}`);
|
||||
// send message to server if needed
|
||||
break;
|
||||
case 'status':
|
||||
let current_ts = Date.now();
|
||||
let source_ts = payload.payload?.timestamp ?? current_ts;
|
||||
|
||||
let diff_ts = current_ts - source_ts;
|
||||
|
||||
if (diff_ts == 0) {
|
||||
// no source
|
||||
addNotification('WARN:Unknown time detected in status check.');
|
||||
} else if (diff_ts > 0 && diff_ts < SLOW_PAYLOAD_RESPONSE_MS) {
|
||||
// is acceptable
|
||||
} else {
|
||||
addNotification('WARN:Slow adb response detected');
|
||||
}
|
||||
|
||||
break;
|
||||
case 'recipe-export':
|
||||
if (payload.payload?.content) {
|
||||
|
|
@ -347,8 +366,73 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
|
||||
recipeFromMachine.set(recipeRawFromMachine);
|
||||
} else {
|
||||
if (sub_type == null) {
|
||||
try {
|
||||
let big_recipe = JSON.parse(JSON.stringify(payload.payload));
|
||||
logger.info(`get large recipe keys: ${Object.keys(big_recipe)}`);
|
||||
|
||||
let recipes = JSON.parse(big_recipe?.recipes);
|
||||
let materials = JSON.parse(big_recipe?.materials);
|
||||
let toppings = JSON.parse(big_recipe?.toppings);
|
||||
|
||||
let updated = {
|
||||
Recipe01: recipes,
|
||||
MaterialSetting: materials,
|
||||
Topping: toppings
|
||||
};
|
||||
|
||||
recipeFromMachine.set(updated);
|
||||
|
||||
// =====================================
|
||||
//
|
||||
// Build Overview
|
||||
//
|
||||
|
||||
let queries = [];
|
||||
for (let rp of recipes) {
|
||||
queries.push({
|
||||
productCode: rp.productCode ?? '<not set>',
|
||||
name: rp.name ? rp.name : (rp.otherName ?? '<not set>'),
|
||||
description: rp.description
|
||||
? rp.description
|
||||
: (rp.otherDescription ?? '<not set>'),
|
||||
tags: buildTags(rp),
|
||||
status: getMenuStatus(rp.MenuStatus)
|
||||
});
|
||||
}
|
||||
|
||||
let cached_query_from_machine = get(recipeFromMachineQuery);
|
||||
|
||||
cached_query_from_machine = {
|
||||
...cached_query_from_machine,
|
||||
recipe: queries
|
||||
};
|
||||
|
||||
recipeFromMachineQuery.set(cached_query_from_machine);
|
||||
|
||||
// =====================================
|
||||
//
|
||||
// Send finish signal
|
||||
|
||||
GlobalEventBus.emitUntilConsumed('recipe-event', {
|
||||
type: 'load-recipe',
|
||||
status: 'end',
|
||||
reload: true
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('some promise failed: ', e);
|
||||
|
||||
GlobalEventBus.emitUntilConsumed('recipe-event', {
|
||||
type: 'load-recipe-fail',
|
||||
status: 'end',
|
||||
reload: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info(`unhandled sub type: %${sub_type}%`);
|
||||
}
|
||||
|
||||
// unhandled sub type
|
||||
logger.info('unhandled sub type', payload);
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -22,11 +22,14 @@ import {
|
|||
handleSheetStreamEnd,
|
||||
handleSheetStreamError,
|
||||
handleCatalogsResponse,
|
||||
handlePriceSlotsResponse,
|
||||
isPriceSlotsPayload,
|
||||
handleListMenuResponse,
|
||||
sheetCatalogsLoading,
|
||||
handleRawStreamHeader,
|
||||
handleRawStreamChunk,
|
||||
handleRawStreamEnd
|
||||
handleRawStreamEnd,
|
||||
handleSheetPriceResponse
|
||||
} from '../stores/sheetStore';
|
||||
import {
|
||||
handleGenLayoutBatchStart,
|
||||
|
|
@ -49,10 +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[]>([]);
|
||||
|
||||
|
|
@ -258,14 +261,23 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
// Handle gen-service responses
|
||||
if (from === 'gen-service') {
|
||||
switch (level) {
|
||||
case 'batch_start':
|
||||
case 'batch_start': {
|
||||
const genRef = p.ref ?? 'gen-layout';
|
||||
handleGenLayoutBatchStart({
|
||||
batch_id: p.batch_id,
|
||||
total_files: p.total_files,
|
||||
total_size_bytes: p.total_size_bytes
|
||||
total_size_bytes: p.total_size_bytes,
|
||||
ref: genRef
|
||||
});
|
||||
addNotification(`INFO:Gen Layout started (${p.total_files} files)`);
|
||||
const genLabel =
|
||||
genRef === 'gen-price'
|
||||
? 'Gen Price'
|
||||
: genRef === 'gen-priceslot'
|
||||
? 'Gen PriceSlot'
|
||||
: 'Gen Layout';
|
||||
addNotification(`INFO:${genLabel} started (${p.total_files} files)`);
|
||||
break;
|
||||
}
|
||||
case 'file':
|
||||
handleGenLayoutFile({
|
||||
batch_id: p.batch_id,
|
||||
|
|
@ -284,11 +296,11 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
batch_id: p.batch_id,
|
||||
total_files: p.total_files
|
||||
});
|
||||
addNotification('INFO:Gen Layout complete');
|
||||
addNotification('INFO:Generation complete');
|
||||
break;
|
||||
case 'ERROR':
|
||||
handleGenLayoutError(msg);
|
||||
addNotification(`ERR:Gen Layout error: ${msg}`);
|
||||
addNotification(`ERR:Generation error: ${msg}`);
|
||||
break;
|
||||
default:
|
||||
logger.info('[GenService] Received:', level, msg);
|
||||
|
|
@ -298,26 +310,76 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
|
||||
if (from === 'sheet-service' && level === 'content') {
|
||||
const currentUid = auth.currentUser?.uid;
|
||||
const content = p.content ?? p.value ?? p.payload;
|
||||
const ref = p.ref ?? '';
|
||||
|
||||
if (target && currentUid && target === currentUid) {
|
||||
if (!msg && p.content?.catalogs) {
|
||||
handleCatalogsResponse(p.content);
|
||||
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
|
||||
logger.debug('[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':
|
||||
if (ref === 'price') {
|
||||
addNotification('INFO:Sheet price streaming started');
|
||||
} else {
|
||||
handleSheetStreamStart(p);
|
||||
addNotification('INFO:Sheet data streaming started');
|
||||
}
|
||||
break;
|
||||
case 'chunk':
|
||||
if (isPriceSlotsPayload(content)) {
|
||||
handlePriceSlotsResponse(content);
|
||||
} else if (ref === 'price') {
|
||||
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
|
||||
} else {
|
||||
handleSheetStreamChunk(p);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
if (ref === 'price') {
|
||||
addNotification('INFO:Sheet price streaming complete');
|
||||
} else {
|
||||
handleSheetStreamEnd(p);
|
||||
addNotification('INFO:Sheet data streaming complete');
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
handleSheetStreamError(p);
|
||||
|
|
@ -325,8 +387,17 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
break;
|
||||
default:
|
||||
// Handle other content notifications from sheet-service
|
||||
logger.info('[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;
|
||||
}
|
||||
|
|
@ -404,6 +475,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
|
||||
});
|
||||
|
|
@ -466,7 +538,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
heartbeat: (p) => {
|
||||
socketConnectionOfflineCount.set(0);
|
||||
socketAlreadySendHeartbeat.set(0);
|
||||
logger.info('heartbeat reset offline count');
|
||||
// logger.info('heartbeat reset offline count');
|
||||
},
|
||||
// Raw stream handlers for sheet data (e.g., price)
|
||||
raw_stream: (p) => {
|
||||
|
|
@ -481,14 +553,23 @@ 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);
|
||||
|
|
@ -496,39 +577,65 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
remote_shell: (p) => {
|
||||
// Server requests command execution on connected Android device
|
||||
handleIncomingRemoteShell(p);
|
||||
},
|
||||
firmware_confirm: (p) => {
|
||||
// console.log(`received firmware confirmation: ${JSON.stringify(p)}`);
|
||||
const items = Object.entries(p.history ?? {}).map(([filename, msg]) => ({
|
||||
filename,
|
||||
message: Object.entries(msg as any).map((x) => x[1])
|
||||
}));
|
||||
GlobalEventBus.emit('announce', {
|
||||
title: 'Firmware Confirmation',
|
||||
subtitle: `Please confirm this build contents. Session Ref: ${p.session}`,
|
||||
message: items,
|
||||
buttonText: 'Confirm',
|
||||
type: 'info'
|
||||
});
|
||||
},
|
||||
firmware_report: (p) => {
|
||||
console.log(`received firmware report: ${JSON.stringify(p)}`);
|
||||
},
|
||||
firmware_versions: (p) => {
|
||||
GlobalEventBus.emit('firmware-versions', p.versions ?? {});
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
// logger.info(`[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));
|
||||
|
||||
addNotification('INFO:Secured Connection');
|
||||
// addNotification('INFO:Secured Connection');
|
||||
|
||||
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);
|
||||
|
||||
// preprocess type
|
||||
actual_message.type = actual_message.type.replace('-', '_');
|
||||
|
||||
handlers[actual_message.type]?.(actual_message.payload);
|
||||
}
|
||||
} else {
|
||||
const msg: WSMessage = JSON.parse(raw);
|
||||
const msg: WSMessage = parsedMessage;
|
||||
if (msg.type !== 'heartbeat') {
|
||||
// logger.debug(`[WS MSG] type=${msg.type}`, msg.payload);
|
||||
}
|
||||
if (msg == null) {
|
||||
// error response
|
||||
addNotification('ERR:No response from server');
|
||||
|
|
|
|||
|
|
@ -1,12 +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[]>([]);
|
||||
|
||||
|
|
@ -23,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);
|
||||
|
||||
|
|
@ -85,6 +117,13 @@ export async function sendMessage(
|
|||
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
|
||||
}
|
||||
|
||||
// logger.debug('[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;
|
||||
}
|
||||
|
|
|
|||
178
src/lib/core/scrcpy/scrcpy-manager.ts
Normal file
178
src/lib/core/scrcpy/scrcpy-manager.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* ScrcpyManager — Lifecycle manager for @yume-chan/adb-scrcpy screen mirroring.
|
||||
*
|
||||
* Handles:
|
||||
* - Fetching the scrcpy-server binary (cached after first download)
|
||||
* - Pushing the binary to the Android device
|
||||
* - Starting a scrcpy session (video + optional control)
|
||||
* - Exposing the AdbScrcpyClient for the dialog to consume the video stream
|
||||
* - Clean teardown on close
|
||||
*
|
||||
* IMPORTANT: This manager does NOT consume the video stream. The dialog is
|
||||
* responsible for reading packets from client.videoStream.stream.
|
||||
*/
|
||||
|
||||
import { AdbScrcpyClient, AdbScrcpyOptions2_3 } from '@yume-chan/adb-scrcpy';
|
||||
import { ReadableStream } from '@yume-chan/stream-extra';
|
||||
import { AdbInstance } from '../../../routes/state.svelte';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ScrcpyStatusStep = 'fetching' | 'pushing' | 'starting' | 'ready' | 'error';
|
||||
|
||||
export interface ScrcpyStartOptions {
|
||||
/** Max video width (0 = no limit). Default 1024. */
|
||||
maxSize?: number;
|
||||
/** Video bitrate in bits/s. Default 8 000 000. */
|
||||
videoBitRate?: number;
|
||||
/** Video codec. Default "h264". */
|
||||
videoCodec?: 'h264' | 'h265' | 'av1';
|
||||
/** Max FPS (0 = no limit). Default 0. */
|
||||
maxFps?: number;
|
||||
/** Enable touch / key control. Default true. */
|
||||
control?: boolean;
|
||||
/** Push a specific server version tag. */
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Path where the scrcpy-server binary lives on the Android device. */
|
||||
const SCRCPY_SERVER_DEVICE_PATH = '/data/local/tmp/scrcpy-server.jar';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary cache (module-level — persists across session starts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedBinaryData: Uint8Array | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createOptions(init: ScrcpyStartOptions) {
|
||||
return new AdbScrcpyOptions2_3({
|
||||
tunnelForward: true,
|
||||
video: true,
|
||||
audio: false,
|
||||
control: init.control ?? true,
|
||||
maxSize: init.maxSize ?? 1024,
|
||||
videoCodec: init.videoCodec ?? 'h264',
|
||||
videoBitRate: init.videoBitRate ?? 8_000_000,
|
||||
maxFps: init.maxFps ?? 0,
|
||||
sendFrameMeta: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the scrcpy-server binary. Uses the local SvelteKit endpoint
|
||||
* (/api/scrcpy-server) to avoid CORS issues with GitHub.
|
||||
* Binary data is cached in memory after the first fetch.
|
||||
*/
|
||||
async function fetchServerBinary(): Promise<ReadableStream<Uint8Array>> {
|
||||
if (cachedBinaryData) {
|
||||
logger.info('[ScrcpyManager] Using cached scrcpy-server binary');
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(cachedBinaryData!);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('[ScrcpyManager] Fetching scrcpy-server from /api/scrcpy-server…');
|
||||
const response = await fetch('/api/scrcpy-server');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch scrcpy-server: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
cachedBinaryData = new Uint8Array(buffer);
|
||||
logger.info(`[ScrcpyManager] Binary cached (${cachedBinaryData.length} bytes)`);
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(cachedBinaryData!);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScrcpyManager class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ScrcpyManager {
|
||||
private _client: AdbScrcpyClient<ReturnType<typeof createOptions>> | null = null;
|
||||
|
||||
get client(): AdbScrcpyClient<ReturnType<typeof createOptions>> | null {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a scrcpy screen-mirroring session.
|
||||
*
|
||||
* @param opts Configuration overrides (bitrate, codec, maxSize, …).
|
||||
* @param onStatus Optional callback for step-by-step progress reporting.
|
||||
* @returns The AdbScrcpyClient instance for the active session.
|
||||
*/
|
||||
async start(
|
||||
opts: ScrcpyStartOptions = {},
|
||||
onStatus?: (step: ScrcpyStatusStep, detail?: string) => void
|
||||
): Promise<AdbScrcpyClient<ReturnType<typeof createOptions>>> {
|
||||
// Clean up any existing session first.
|
||||
if (this._client) {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
const adb = AdbInstance.instance;
|
||||
if (!adb) {
|
||||
throw new Error('No ADB device connected');
|
||||
}
|
||||
|
||||
// 1. Fetch the scrcpy-server binary (cached after first download).
|
||||
onStatus?.('fetching', cachedBinaryData ? 'Using cached binary' : 'Downloading scrcpy-server…');
|
||||
const serverBinary = await fetchServerBinary();
|
||||
|
||||
// 2. Push the binary to the device.
|
||||
onStatus?.('pushing', 'Pushing to device…');
|
||||
await AdbScrcpyClient.pushServer(adb, serverBinary, SCRCPY_SERVER_DEVICE_PATH);
|
||||
logger.info('[ScrcpyManager] Server binary pushed.');
|
||||
|
||||
// 3. Start the scrcpy session.
|
||||
onStatus?.('starting', 'Starting session…');
|
||||
const options = createOptions(opts);
|
||||
const client = await AdbScrcpyClient.start(adb, SCRCPY_SERVER_DEVICE_PATH, options);
|
||||
logger.info('[ScrcpyManager] Session started.');
|
||||
|
||||
this._client = client;
|
||||
onStatus?.('ready', 'Connected');
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Tear down the current session. */
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
if (this._client) {
|
||||
await this._client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[ScrcpyManager] Error closing session', e);
|
||||
} finally {
|
||||
this._client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance — import and use anywhere. */
|
||||
export const scrcpyManager = new ScrcpyManager();
|
||||
|
|
@ -8,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,
|
||||
|
|
@ -19,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
|
||||
};
|
||||
logger.debug('[sheetService] Sending PriceSlot request:', values);
|
||||
const sent = await sendCommandRequest('sheet', values);
|
||||
logger.debug('[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', {
|
||||
// logger.debug('[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}`
|
||||
});
|
||||
|
||||
logger.debug('[sheetService] PriceSlot update sent:', {
|
||||
country,
|
||||
slot: slot.slot,
|
||||
sent
|
||||
});
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
export async function addPriceSlot(
|
||||
country: string,
|
||||
slot: PriceSlot,
|
||||
content: SheetRowCreate[]
|
||||
): Promise<boolean> {
|
||||
logger.debug('[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}`
|
||||
});
|
||||
|
||||
logger.debug('[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}`
|
||||
});
|
||||
|
||||
logger.debug('[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}`
|
||||
});
|
||||
|
||||
logger.debug('[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> {
|
||||
|
|
@ -165,7 +325,7 @@ export async function requestGenLayout(country: string): Promise<boolean> {
|
|||
};
|
||||
}
|
||||
|
||||
setGenLayoutGenerating();
|
||||
setGenLayoutGenerating('gen-layout');
|
||||
|
||||
logger.info('[sheetService] Sending gen-layout request for country:', country);
|
||||
|
||||
|
|
@ -184,13 +344,90 @@ export async function requestGenLayout(country: string): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger gen-service to (re)generate the machine price profile from Grist.
|
||||
* Backend: taobin_admin app.py `gen-price` branch -> gen_price_supra.gen_price.
|
||||
* Produces profile_<C>_master.json, commits to recipe-repo, streams files back
|
||||
* (ref='gen-price') for the frontend to push to the machine.
|
||||
*/
|
||||
export async function requestGenPrice(country: string): Promise<boolean> {
|
||||
const curr_user = get(auth);
|
||||
|
||||
let user_info: any = {};
|
||||
if (curr_user) {
|
||||
user_info = {
|
||||
displayName: curr_user.displayName,
|
||||
email: curr_user.email,
|
||||
uid: curr_user.uid
|
||||
};
|
||||
}
|
||||
|
||||
setGenLayoutGenerating('gen-price');
|
||||
|
||||
logger.info('[sheetService] Sending gen-price request for country:', country);
|
||||
|
||||
return await sendMessage({
|
||||
type: 'command',
|
||||
payload: {
|
||||
user_info,
|
||||
srv_name: 'gen-service',
|
||||
values: {
|
||||
file_layout: 'sheet',
|
||||
file_desc: 'sheet',
|
||||
country: country,
|
||||
param: 'gen-price-supra_app'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger gen-service to (re)generate the per-slot price profiles from Grist.
|
||||
* Backend: taobin_admin app.py `gen-priceslot` branch -> gen_price_supra.gen_priceslot.
|
||||
* Produces profile_<C>_slot_<N>.json (ref='gen-priceslot').
|
||||
*/
|
||||
export async function requestGenPriceSlot(country: string): Promise<boolean> {
|
||||
const curr_user = get(auth);
|
||||
|
||||
let user_info: any = {};
|
||||
if (curr_user) {
|
||||
user_info = {
|
||||
displayName: curr_user.displayName,
|
||||
email: curr_user.email,
|
||||
uid: curr_user.uid
|
||||
};
|
||||
}
|
||||
|
||||
setGenLayoutGenerating('gen-priceslot');
|
||||
|
||||
logger.info('[sheetService] Sending gen-priceslot request for country:', country);
|
||||
|
||||
return await sendMessage({
|
||||
type: 'command',
|
||||
payload: {
|
||||
user_info,
|
||||
srv_name: 'gen-service',
|
||||
values: {
|
||||
file_layout: 'sheet',
|
||||
file_desc: 'sheet',
|
||||
country: country,
|
||||
param: 'gen-priceslot-supra_app'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')) {
|
||||
if (!force && hasSheetPriceBeenSent('price')) {
|
||||
logger.warn('[sheetService] Price request already sent, skipping');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -232,6 +469,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
|
|||
country: country,
|
||||
content: content,
|
||||
param: 'price',
|
||||
option: 'price',
|
||||
stream: true,
|
||||
request_id
|
||||
});
|
||||
logger.debug('[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);
|
||||
|
||||
logger.debug('[sheetService] Sending all sheet price request:', { country, request_id });
|
||||
|
||||
const sent = await sendCommandRequest('sheet', {
|
||||
country,
|
||||
content: [],
|
||||
param: 'price',
|
||||
option: 'price',
|
||||
stream: true,
|
||||
request_id
|
||||
});
|
||||
|
|
|
|||
68
src/lib/core/stores/adbReconnectStore.ts
Normal file
68
src/lib/core/stores/adbReconnectStore.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* ADB reconnection prompt store.
|
||||
*
|
||||
* When the Android server socket dies, instead of silently reconnecting
|
||||
* in the background, we surface a persistent banner asking the user to
|
||||
* confirm the reconnection. This gives the user control over when the
|
||||
* USB/TCP handshake happens (it can be disruptive) while still making
|
||||
* it obvious that the connection is down.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Stream dies or write fails → `requestReconnect('reason')`
|
||||
* 2. Banner appears on every authed page
|
||||
* 3. User taps "Reconnect" → `confirmReconnect()` fires the
|
||||
* stored callback, then clears the pending state.
|
||||
* 4. User dismisses → `dismissReconnect()` clears the state
|
||||
* without reconnecting.
|
||||
*/
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
export interface ReconnectRequest {
|
||||
/** Human-readable reason (shown in the banner). */
|
||||
reason: string;
|
||||
/** Timestamp when the reconnect was requested. */
|
||||
requestedAt: Date;
|
||||
}
|
||||
|
||||
function createAdbReconnectStore() {
|
||||
const { subscribe, set } = writable<ReconnectRequest | null>(null);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Signal that the ADB socket has died and a reconnection is needed.
|
||||
* This shows the banner — the user must confirm before we actually
|
||||
* reconnect.
|
||||
*/
|
||||
requestReconnect(reason: string = 'Android socket disconnected') {
|
||||
// Don't overwrite if a request is already pending
|
||||
if (get({ subscribe }) !== null) return;
|
||||
set({ reason, requestedAt: new Date() });
|
||||
},
|
||||
|
||||
/**
|
||||
* The user confirmed they want to reconnect.
|
||||
* Callers should perform the reconnection after calling this.
|
||||
*/
|
||||
confirmReconnect() {
|
||||
set(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* The user dismissed the banner without reconnecting.
|
||||
*/
|
||||
dismissReconnect() {
|
||||
set(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether a reconnection is currently pending.
|
||||
*/
|
||||
isPending(): boolean {
|
||||
return get({ subscribe }) !== null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const adbReconnect = createAdbReconnectStore();
|
||||
|
|
@ -1,12 +1,27 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { addNotification } from './noti';
|
||||
import { adbReconnect } from './adbReconnectStore';
|
||||
|
||||
const adbWriter: any = writable(null);
|
||||
|
||||
/**
|
||||
* Tracks whether the Android socket was ever alive in this session.
|
||||
* Used to distinguish:
|
||||
* - First visit (never connected) → auto-connect silently
|
||||
* - Reconnection (was alive, now dead) → show reconnect banner
|
||||
*/
|
||||
let _wasEverAlive = false;
|
||||
|
||||
// Subscribe to writer changes to track liveness
|
||||
adbWriter.subscribe((writer: any) => {
|
||||
if (writer && writer.desiredSize !== null && writer.desiredSize !== undefined) {
|
||||
_wasEverAlive = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function sendToAndroid(message: any) {
|
||||
let writer: any = get(adbWriter);
|
||||
// logger.info('writer', writer);
|
||||
if (!writer) {
|
||||
addNotification('ERR:No active Android connection');
|
||||
return false;
|
||||
|
|
@ -15,26 +30,65 @@ async function sendToAndroid(message: any) {
|
|||
const encoder = new TextEncoder();
|
||||
const serializedMessage = JSON.stringify(message);
|
||||
await writer.write(encoder.encode(serializedMessage + '\n'));
|
||||
// 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) {
|
||||
logger.error('write failed', error);
|
||||
// The underlying stream is dead — clear the writer so
|
||||
// callers don't keep trying to use a stale reference.
|
||||
adbWriter.set(null);
|
||||
addNotification('ERR:Failed to send message to Android');
|
||||
// Prompt the user to reconnect (this was alive before → reconnection)
|
||||
adbReconnect.requestReconnect('Android socket write failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// helper function for checking if connection is ok
|
||||
/**
|
||||
* Check whether the ADB writer store currently holds a reference.
|
||||
*
|
||||
* ⚠ This only checks if a writer object is present — it does NOT
|
||||
* guarantee the underlying stream is alive. Use {@link isAdbWriterAlive}
|
||||
* in page code to verify + reconnect if needed.
|
||||
*/
|
||||
function isAdbWriterAvailable() {
|
||||
return get(adbWriter) != null;
|
||||
}
|
||||
|
||||
export { sendToAndroid, adbWriter, isAdbWriterAvailable };
|
||||
/**
|
||||
* Attempt a lightweight probe to check if the writer's stream is still
|
||||
* writable. Returns `true` if the writer appears alive, `false` if
|
||||
* the stream has been closed or the writer is stale.
|
||||
*
|
||||
* The probe checks `writer.desiredSize` — a standard
|
||||
* `WritableStreamDefaultWriter` property that returns `null` once
|
||||
* the stream has been closed or errored.
|
||||
*
|
||||
* If a stale writer is detected (was alive, now dead), the reconnect
|
||||
* banner is triggered.
|
||||
*/
|
||||
function isAdbWriterAlive(): boolean {
|
||||
const writer: any = get(adbWriter);
|
||||
if (!writer) return false;
|
||||
|
||||
// WritableStreamDefaultWriter.desiredSize is null after close/error
|
||||
if (writer.desiredSize === null) {
|
||||
adbWriter.set(null);
|
||||
adbReconnect.requestReconnect('Android socket is stale');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the Android socket was ever successfully connected
|
||||
* in this browser session.
|
||||
*
|
||||
* Used to decide between auto-connect (first visit) and prompted
|
||||
* reconnect (socket died after being alive).
|
||||
*/
|
||||
function wasAndroidSocketEverAlive(): boolean {
|
||||
return _wasEverAlive;
|
||||
}
|
||||
|
||||
export { sendToAndroid, adbWriter, isAdbWriterAvailable, isAdbWriterAlive, wasAndroidSocketEverAlive };
|
||||
|
|
|
|||
287
src/lib/core/stores/firmwareStore.ts
Normal file
287
src/lib/core/stores/firmwareStore.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Firmware request store — manages per-country firmware build requests
|
||||
* and available firmware files.
|
||||
*
|
||||
* The data structure is designed to be wired up to a backend API later.
|
||||
* For now it uses a writable store with mock data so the UI can be
|
||||
* developed and tested independently.
|
||||
*/
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { sendMessage } from '../handlers/ws_messageSender';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FirmwareBuildStatus = 'idle' | 'queued' | 'building' | 'ready' | 'failed';
|
||||
|
||||
export interface FirmwareFile {
|
||||
/** Unique identifier. */
|
||||
id: string;
|
||||
/** Filename e.g. `supra-v1.2.3-tha.bin` */
|
||||
filename: string;
|
||||
/** File size in bytes. */
|
||||
size: number;
|
||||
/** Build date ISO string. */
|
||||
builtAt: string;
|
||||
/** Firmware version string. */
|
||||
version: string;
|
||||
/** Download URL (populated when status === 'ready'). */
|
||||
downloadUrl?: string;
|
||||
/** Status of the build. */
|
||||
status: FirmwareBuildStatus;
|
||||
/** Optional status message / error detail. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FirmwareCountryState {
|
||||
/** Country code (e.g. 'tha', 'sgp'). */
|
||||
country: string;
|
||||
/** Human-readable country label. */
|
||||
label: string;
|
||||
/** Current build status for this country. */
|
||||
buildStatus: FirmwareBuildStatus;
|
||||
/** List of firmware files for this country. */
|
||||
files: FirmwareFile[];
|
||||
}
|
||||
|
||||
export interface FirmwareBuildRequest {
|
||||
country: string;
|
||||
version: string;
|
||||
notes?: string;
|
||||
requester: string;
|
||||
param?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Country list (derived from the same codes used in productCode.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FIRMWARE_COUNTRIES = [
|
||||
{ code: 'tha', label: 'Thailand' },
|
||||
{ code: 'mys', label: 'Malaysia' },
|
||||
{ code: 'idr', label: 'Indonesia' },
|
||||
{ code: 'aus', label: 'Australia' },
|
||||
{ code: 'sgp', label: 'Singapore' },
|
||||
{ code: 'dubai', label: 'UAE Dubai' },
|
||||
{ code: 'hkg', label: 'Hong Kong' },
|
||||
{ code: 'gbr', label: 'United Kingdom' },
|
||||
{ code: 'rou', label: 'Romania' },
|
||||
{ code: 'lva', label: 'Latvia' },
|
||||
{ code: 'est', label: 'Estonia' },
|
||||
{ code: 'ltu', label: 'Lithuania' }
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFirmwareStore() {
|
||||
const { subscribe, set, update } = writable<Record<string, FirmwareCountryState>>({});
|
||||
|
||||
// Initialise with empty state for each country
|
||||
const initial: Record<string, FirmwareCountryState> = {};
|
||||
for (const c of FIRMWARE_COUNTRIES) {
|
||||
initial[c.code] = {
|
||||
country: c.code,
|
||||
label: c.label,
|
||||
buildStatus: 'idle',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
set(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update,
|
||||
|
||||
/**
|
||||
* Request a new firmware build for a country.
|
||||
*/
|
||||
requestBuild(req: FirmwareBuildRequest) {
|
||||
const countryState = get(firmwareStore)[req.country];
|
||||
if (!countryState) return;
|
||||
if (countryState.buildStatus === 'queued' || countryState.buildStatus === 'building') {
|
||||
return; // Already in progress
|
||||
}
|
||||
|
||||
// Set to queued
|
||||
update((state) => ({
|
||||
...state,
|
||||
[req.country]: {
|
||||
...state[req.country],
|
||||
buildStatus: 'queued',
|
||||
files: [
|
||||
{
|
||||
id: `${req.country}-${Date.now()}`,
|
||||
filename: '',
|
||||
size: 0,
|
||||
builtAt: new Date().toISOString(),
|
||||
version: 'unknown',
|
||||
status: 'queued',
|
||||
message: req.notes
|
||||
},
|
||||
...state[req.country].files
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
// Simulate build lifecycle (replace with real WS call later)
|
||||
// simulateBuild(req.country);
|
||||
|
||||
sendMessage({
|
||||
type: 'firmware-request',
|
||||
payload: {
|
||||
mode: 'not_full-delay-build',
|
||||
brand: req.country,
|
||||
files: [],
|
||||
param: (req.param as any[]) ?? [],
|
||||
requester: req.requester
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the file list for a country.
|
||||
*/
|
||||
refreshFiles(country: string) {
|
||||
// In production, fetch from server. No-op for now.
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the store (useful for testing).
|
||||
*/
|
||||
reset() {
|
||||
const fresh: Record<string, FirmwareCountryState> = {};
|
||||
for (const c of FIRMWARE_COUNTRIES) {
|
||||
fresh[c.code] = {
|
||||
country: c.code,
|
||||
label: c.label,
|
||||
buildStatus: 'idle',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
set(fresh);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const firmwareStore = createFirmwareStore();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulate a build lifecycle: queued → building → ready.
|
||||
* This is a placeholder until the real backend is wired up.
|
||||
*/
|
||||
function simulateBuild(country: string) {
|
||||
// queued → building (1s)
|
||||
setTimeout(() => {
|
||||
firmwareStore.update((state) => ({
|
||||
...state,
|
||||
[country]: {
|
||||
...state[country],
|
||||
buildStatus: 'building',
|
||||
files: state[country].files.map((f: FirmwareFile, i: number) =>
|
||||
i === 0 ? { ...f, status: 'building' as FirmwareBuildStatus } : f
|
||||
)
|
||||
}
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
// building → ready (3s)
|
||||
setTimeout(() => {
|
||||
const mockSize = 2_400_000 + Math.floor(Math.random() * 800_000); // ~2.4–3.2 MB
|
||||
firmwareStore.update((state: Record<string, FirmwareCountryState>) => ({
|
||||
...state,
|
||||
[country]: {
|
||||
...state[country],
|
||||
buildStatus: 'ready',
|
||||
files: state[country].files.map((f: FirmwareFile, i: number) =>
|
||||
i === 0
|
||||
? {
|
||||
...f,
|
||||
status: 'ready' as FirmwareBuildStatus,
|
||||
size: mockSize,
|
||||
builtAt: new Date().toISOString(),
|
||||
downloadUrl: `#/download/${f.filename}`
|
||||
}
|
||||
: f
|
||||
)
|
||||
}
|
||||
}));
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into a human-readable string.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string into a short local date-time.
|
||||
*/
|
||||
export function formatBuildDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a build status to a badge variant for the UI.
|
||||
*/
|
||||
export function statusBadgeVariant(
|
||||
status: FirmwareBuildStatus
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'default';
|
||||
case 'building':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'queued':
|
||||
case 'idle':
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a build status to a human-readable label.
|
||||
*/
|
||||
export function statusLabel(status: FirmwareBuildStatus): string {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Idle';
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'building':
|
||||
return 'Building';
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,20 @@ export interface GenLayoutFile {
|
|||
file_index: number;
|
||||
}
|
||||
|
||||
// Kind of gen-service batch. Distinguishes which files were generated so the
|
||||
// push logic can map them to the correct machine path.
|
||||
// gen-layout -> layout files under taobin_project/
|
||||
// gen-price -> profile_<C>_master.json -> /sdcard/coffeevending/profile/
|
||||
// gen-priceslot -> profile_<C>_slot_<N>.json -> .../price/
|
||||
export type GenKind = 'gen-layout' | 'gen-price' | 'gen-priceslot';
|
||||
|
||||
export interface GenLayoutBatch {
|
||||
batch_id: string;
|
||||
total_files: number;
|
||||
total_size_bytes: number;
|
||||
status: 'idle' | 'generating' | 'receiving' | 'complete' | 'error';
|
||||
files: GenLayoutFile[];
|
||||
ref: GenKind;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +37,8 @@ const initialState: GenLayoutBatch = {
|
|||
total_files: 0,
|
||||
total_size_bytes: 0,
|
||||
status: 'idle',
|
||||
files: []
|
||||
files: [],
|
||||
ref: 'gen-layout'
|
||||
};
|
||||
|
||||
export const genLayoutBatch = writable<GenLayoutBatch>(initialState);
|
||||
|
|
@ -52,15 +61,17 @@ export function handleGenLayoutBatchStart(payload: {
|
|||
batch_id: string;
|
||||
total_files: number;
|
||||
total_size_bytes: number;
|
||||
ref?: GenKind;
|
||||
}) {
|
||||
genLayoutBatch.set({
|
||||
batch_id: payload.batch_id,
|
||||
total_files: payload.total_files,
|
||||
total_size_bytes: payload.total_size_bytes,
|
||||
status: 'receiving',
|
||||
files: []
|
||||
files: [],
|
||||
ref: payload.ref ?? get(genLayoutBatch).ref ?? 'gen-layout'
|
||||
});
|
||||
logger.info('[GenLayout] Batch started:', payload.batch_id, 'total files:', payload.total_files);
|
||||
logger.info('[GenLayout] Batch started:', payload.batch_id, 'ref:', payload.ref, 'total files:', payload.total_files);
|
||||
}
|
||||
|
||||
export function handleGenLayoutFile(payload: {
|
||||
|
|
@ -79,7 +90,10 @@ export function handleGenLayoutFile(payload: {
|
|||
|
||||
if (payload.is_chunked) {
|
||||
const fileIndex = payload.file_index;
|
||||
const partIndex = payload.part_index ?? 0;
|
||||
// Backend sends part_index 1-based (1..N); normalize to 0-based so it
|
||||
// matches the 0..totalParts-1 assembly loop below. Without this, part 1
|
||||
// was stored empty and the last chunk was dropped (files > 1MB corrupted).
|
||||
const partIndex = (payload.part_index ?? 1) - 1;
|
||||
const totalParts = payload.total_parts ?? 1;
|
||||
|
||||
// Get or create tracker for this file
|
||||
|
|
@ -242,9 +256,10 @@ export function resetGenLayoutBatch() {
|
|||
chunkedFiles.clear();
|
||||
}
|
||||
|
||||
export function setGenLayoutGenerating() {
|
||||
export function setGenLayoutGenerating(ref: GenKind = 'gen-layout') {
|
||||
genLayoutBatch.update((batch) => ({
|
||||
...batch,
|
||||
ref,
|
||||
status: 'generating'
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,21 @@ 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 ',
|
||||
'rm',
|
||||
'reboot',
|
||||
'reboot-bootloader',
|
||||
'flash',
|
||||
'format',
|
||||
'mount',
|
||||
'unmount',
|
||||
'dd',
|
||||
'mkfs',
|
||||
'wipe',
|
||||
'install',
|
||||
'uninstall',
|
||||
'pm ',
|
||||
'am ',
|
||||
'svc '
|
||||
];
|
||||
|
||||
function isDangerousCommand(command: string): boolean {
|
||||
|
|
@ -151,13 +163,15 @@ async function executeAndRespond(req: RemoteShellRequest) {
|
|||
* Uses the simpler executeCmd() which properly captures exitCode and stderr.
|
||||
*/
|
||||
async function executeDirect(req: RemoteShellRequest) {
|
||||
const result = await executeCmd(req.commandInput);
|
||||
const result = await executeCmd(req.commandInput, true);
|
||||
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'}`);
|
||||
addNotification(
|
||||
`${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`
|
||||
);
|
||||
|
||||
remoteShellRequest.set({
|
||||
...req,
|
||||
|
|
|
|||
|
|
@ -25,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) {
|
||||
logger.debug('[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;
|
||||
}
|
||||
|
||||
logger.debug('[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',
|
||||
|
|
@ -79,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, {
|
||||
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 },
|
||||
|
|
@ -91,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'
|
||||
},
|
||||
|
|
@ -101,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'
|
||||
},
|
||||
|
|
@ -131,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'
|
||||
},
|
||||
|
|
@ -152,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) {
|
||||
|
|
@ -305,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, {
|
||||
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']
|
||||
|
|
@ -367,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;
|
||||
|
|
@ -383,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(
|
||||
|
|
@ -391,29 +698,43 @@ export function getPriceFromCells(
|
|||
cells: GristCell[],
|
||||
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
|
||||
): string | null {
|
||||
const colIdx = getPriceColumnIndex(country, priceType);
|
||||
if (colIdx < 0) return null;
|
||||
|
||||
// Find the cell with matching column index
|
||||
const priceCell = cells.find((c) => c.coord?.col === colIdx);
|
||||
return priceCell?.value ?? null;
|
||||
}
|
||||
|
||||
export function getPriceColumnIndex(
|
||||
country: string,
|
||||
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
|
||||
): number {
|
||||
const headers = get(sheetPriceHeader)[country];
|
||||
if (!headers || headers.length === 0) {
|
||||
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
|
||||
return null;
|
||||
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;
|
||||
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 null;
|
||||
logger.warn(
|
||||
`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
|
||||
possibleNames
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Find the cell with matching column index
|
||||
const priceCell = cells.find((c) => c.coord?.col === colIdx);
|
||||
//logger.info(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
|
||||
return priceCell?.value ?? null;
|
||||
return colIdx;
|
||||
}
|
||||
|
||||
// Store for tracking streaming state
|
||||
|
|
@ -445,15 +766,20 @@ export const streamingRawData = writable<
|
|||
|
||||
// Handler: raw_stream header (e.g., raw_stream_price)
|
||||
export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||
logger.info(`[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 || '',
|
||||
|
|
@ -462,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 || '',
|
||||
|
|
@ -474,10 +800,18 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
|
|||
|
||||
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
|
||||
export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||
logger.info(`[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';
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
|
||||
payload.raw?.length
|
||||
);
|
||||
|
||||
const streamData = currentData[targetSubtype];
|
||||
|
||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||
logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
|
||||
|
|
@ -489,7 +823,7 @@ 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]
|
||||
|
|
@ -505,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]
|
||||
}
|
||||
}));
|
||||
|
||||
logger.info(`[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) {
|
||||
logger.info(`[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';
|
||||
}
|
||||
|
||||
logger.debug(`[RawStream] End payload for ${targetSubtype}:`, payload);
|
||||
|
||||
const streamData = currentData[targetSubtype];
|
||||
|
||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||
logger.warn(`[RawStream] End received for unknown stream: ${subtype}`);
|
||||
logger.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -555,18 +894,41 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`[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') {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -601,8 +963,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
|
||||
// Find column indices dynamically from header
|
||||
// product_code header is typically "ProductCode" or similar
|
||||
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
|
||||
logger.info(`[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)
|
||||
|
|
@ -703,7 +1075,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
// Log duplicates info
|
||||
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
||||
if (duplicates.length > 0) {
|
||||
logger.info(`[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];
|
||||
|
|
@ -711,6 +1086,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
|||
}
|
||||
}
|
||||
|
||||
export function handleSheetPriceResponse(country: string, content: any) {
|
||||
const resolvedCountry = country || get(streamingRawData).price?.country || '';
|
||||
const chunks = Array.isArray(content) ? content : [content];
|
||||
processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
|
||||
sheetPriceLoading.set(false);
|
||||
}
|
||||
|
||||
// Reset sheet price stores
|
||||
export function resetSheetPriceStore() {
|
||||
sheetPriceStreamMeta.set(null);
|
||||
|
|
@ -770,14 +1152,24 @@ 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) {
|
||||
logger.info('[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 || '';
|
||||
logger.info('[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;
|
||||
}
|
||||
}
|
||||
|
|
@ -799,7 +1191,13 @@ export function clearProductCodes() {
|
|||
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
||||
// Use pending country if not in payload
|
||||
const country = payload.country || pendingProductCodesCountry;
|
||||
logger.info('[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));
|
||||
|
|
@ -815,7 +1213,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
|
|||
timestamp: Date.now()
|
||||
})
|
||||
);
|
||||
logger.info('[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) {
|
||||
logger.warn('[sheetStore] Failed to save to cache:', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,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);
|
||||
|
||||
|
|
@ -54,6 +55,31 @@ 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) {
|
||||
// logger.info('connecting to ', env.PUBLIC_WSS);
|
||||
|
|
@ -64,12 +90,13 @@ 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();
|
||||
|
||||
socket.addEventListener('open', async () => {
|
||||
socketStore.set(socket);
|
||||
addNotification('INFO:Connected!');
|
||||
// addNotification('INFO:Connected!');
|
||||
|
||||
if (socket) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
|
|
@ -89,13 +116,11 @@ 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
|
||||
logger.info('[WS Auth] Sending auth info with:', {
|
||||
uid: auth_data?.uid,
|
||||
name: auth_data?.displayName,
|
||||
email: auth_data?.email
|
||||
});
|
||||
await sendMessage({
|
||||
logger.info('[WS Auth] Sending auth info with [REDACTED]');
|
||||
const sent = await sendMessage({
|
||||
type: 'auth',
|
||||
payload: {
|
||||
user: {
|
||||
|
|
@ -106,11 +131,12 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
}
|
||||
}
|
||||
});
|
||||
wsAuthReady.set(sent);
|
||||
clearInterval(sendAuthInfoInterval);
|
||||
}
|
||||
}, 3000);
|
||||
}, 2000);
|
||||
}
|
||||
logger.info(socket);
|
||||
// logger.info(socket);
|
||||
|
||||
// heartbeat 10s
|
||||
socketCheck = setInterval(async () => {
|
||||
|
|
@ -161,10 +187,12 @@ 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) {
|
||||
logger.info('try reconnect websocket ...');
|
||||
|
|
@ -179,6 +207,7 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
socket.addEventListener('error', (e) => {
|
||||
// logger.info('WebSocket error: ', e);
|
||||
socketStore.set(null);
|
||||
wsAuthReady.set(false);
|
||||
sharedKey.set(null);
|
||||
clearInterval(socketCheck);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,4 +91,25 @@ export type OutMessage =
|
|||
timestamp: number;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-request';
|
||||
payload: {
|
||||
mode: 'not_full' | 'not_full-delay-build';
|
||||
brand: string;
|
||||
files: string[];
|
||||
param: ('new-auto-inc-version' | 'save-build-date' | 'save-build-version-override')[];
|
||||
requester: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-confirm';
|
||||
payload: {
|
||||
confirm: boolean;
|
||||
session: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-versions';
|
||||
payload: {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +1,225 @@
|
|||
/**
|
||||
* Lightweight logger that suppresses output in production.
|
||||
* Structured logger powered by LogTape (@logtape/logtape).
|
||||
*
|
||||
* 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.
|
||||
* Two tiers of logging:
|
||||
* - **logger** — general-purpose logging (suppressed in production for security)
|
||||
* - Dev: `trace`, `debug`, `info`, `warn`, `error` (configurable via PUBLIC_APP_LOG_LEVEL)
|
||||
* - Prod: `info` by default (or what PUBLIC_APP_LOG_LEVEL says)
|
||||
* - **inspector** — error-only logging, ALWAYS active (never suppressed).
|
||||
* Use for security-critical events that must appear in the browser
|
||||
* developer console (e.g. auth failures, API errors).
|
||||
*
|
||||
* Usage:
|
||||
* import { logger } from '$lib/core/utils/logger';
|
||||
* logger.info('hello', someVar);
|
||||
* logger.error('failed', err);
|
||||
* API (100 % backward compatible):
|
||||
* logger.info('message', arg1, …)
|
||||
* logger.error('Failed:', err)
|
||||
* inspector.error('auth failure', uid) // always visible
|
||||
*/
|
||||
import { configureSync, getConfig, getConsoleSink, withFilter, getLogger } from '@logtape/logtape';
|
||||
import type { LogLevel } from '@logtape/logtape';
|
||||
|
||||
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
||||
// ---------------------------------------------------------------------------
|
||||
// LogTape configuration (idempotent — uses getConfig() not a local flag)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_LEVEL_RANK: Record<LogLevel, number> = {
|
||||
trace: -1,
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
/**
|
||||
* Resolve the log level lazily (not at module-eval time).
|
||||
*
|
||||
* import.meta.env is undefined when Vite loads vite.config.ts, so we
|
||||
* must not read it at the top level. Deferring to first use ensures
|
||||
* Vite has finished initialising by the time we read it.
|
||||
*/
|
||||
function resolveLogLevel(): LogLevel {
|
||||
const env = import.meta.env as Record<string, string | boolean | undefined> | undefined;
|
||||
const isDev = env?.DEV ?? (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production');
|
||||
const configured = env?.PUBLIC_APP_LOG_LEVEL as string | undefined;
|
||||
return (configured || (isDev ? 'trace' : 'info')) as LogLevel;
|
||||
}
|
||||
|
||||
function ensureConfigured() {
|
||||
// Vite evaluates the config module twice during build (SSR + client).
|
||||
// A local `_configured` boolean resets between evaluations, so we
|
||||
// rely on LogTape's own global state instead.
|
||||
if (getConfig() !== null) return;
|
||||
configureSync({
|
||||
sinks: {
|
||||
console: getConsoleSink(),
|
||||
errorsOnly: withFilter(getConsoleSink(), 'error'),
|
||||
},
|
||||
loggers: [
|
||||
{
|
||||
category: ['supra-app'],
|
||||
sinks: ['console'],
|
||||
lowestLevel: resolveLogLevel(),
|
||||
},
|
||||
{
|
||||
category: ['supra-app', 'inspector'],
|
||||
sinks: ['errorsOnly'],
|
||||
lowestLevel: 'error',
|
||||
},
|
||||
{
|
||||
category: ['logtape', 'meta'],
|
||||
sinks: ['console'],
|
||||
lowestLevel: 'error',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal LogTape loggers (lazy-init)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getBaseLogger() {
|
||||
ensureConfigured();
|
||||
return getLogger(['supra-app']);
|
||||
}
|
||||
|
||||
function getInspectorLogger() {
|
||||
ensureConfigured();
|
||||
return getLogger(['supra-app', 'inspector']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !(v instanceof Error) && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert variadic args into a flat message string + optional extras.
|
||||
*
|
||||
* Patterns handled:
|
||||
* logger.info('text') → message, no props
|
||||
* logger.info('text', obj) → message, obj as props if obj is a plain Record
|
||||
* logger.info('text', error) → error overload
|
||||
* logger.info('text', arg1, arg2, …) → concatenated message
|
||||
*/
|
||||
function normalize(
|
||||
args: unknown[],
|
||||
): { message: string; error?: Error; properties?: Record<string, unknown> } {
|
||||
if (args.length === 0) return { message: '' };
|
||||
|
||||
const first = args[0];
|
||||
if (args.length === 1) {
|
||||
if (first instanceof Error) return { message: first.message, error: first };
|
||||
return { message: String(first) };
|
||||
}
|
||||
|
||||
if (args.length === 2 && isRecord(args[1])) {
|
||||
return { message: String(first), properties: args[1] };
|
||||
}
|
||||
|
||||
// error overload: logger.warn(msg, error) / logger.error('msg', err)
|
||||
if (args.length === 2 && args[1] instanceof Error) {
|
||||
return { message: String(first), error: args[1] as Error };
|
||||
}
|
||||
|
||||
// multi-arg: concatenate
|
||||
return {
|
||||
message: args.map((a) => (typeof a === 'object' ? safeStringify(a) : String(a))).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
if (v instanceof Error) return `${v.name}: ${v.message}`;
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const logger = {
|
||||
trace(...args: unknown[]) {
|
||||
const { message } = normalize(args);
|
||||
getBaseLogger().trace(message);
|
||||
},
|
||||
|
||||
debug(...args: unknown[]) {
|
||||
const { message, properties } = normalize(args);
|
||||
if (properties) {
|
||||
getBaseLogger().debug(message, properties);
|
||||
} else {
|
||||
getBaseLogger().debug(message);
|
||||
}
|
||||
},
|
||||
|
||||
info(...args: unknown[]) {
|
||||
const { message, properties } = normalize(args);
|
||||
if (properties) {
|
||||
getBaseLogger().info(message, properties);
|
||||
} else {
|
||||
getBaseLogger().info(message);
|
||||
}
|
||||
},
|
||||
|
||||
warn(...args: unknown[]) {
|
||||
const { message, error, properties } = normalize(args);
|
||||
if (properties) {
|
||||
getBaseLogger().warn(message, properties);
|
||||
} else if (error) {
|
||||
getBaseLogger().warn(message, error);
|
||||
} else {
|
||||
getBaseLogger().warn(message);
|
||||
}
|
||||
},
|
||||
|
||||
error(...args: unknown[]) {
|
||||
const { message, error, properties } = normalize(args);
|
||||
if (error && properties) {
|
||||
getBaseLogger().error(error, properties);
|
||||
} else if (error) {
|
||||
getBaseLogger().error(error);
|
||||
} else if (properties) {
|
||||
getBaseLogger().error(message, properties);
|
||||
} else {
|
||||
getBaseLogger().error(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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.
|
||||
* Inspector logger — always active, error-only, never suppressed.
|
||||
* Messages are written via the same LogTape infrastructure but on a
|
||||
* separate category (supra-app → inspector) whose sink is hard-filtered to
|
||||
* `error` / `fatal` so security-sensitive events are always visible.
|
||||
*/
|
||||
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';
|
||||
export const inspector = {
|
||||
error(...args: unknown[]) {
|
||||
const { message, error, properties } = normalize(args);
|
||||
if (error && properties) {
|
||||
getInspectorLogger().error(error, properties);
|
||||
} else if (error) {
|
||||
getInspectorLogger().error(error);
|
||||
} else if (properties) {
|
||||
getInspectorLogger().error(message, properties);
|
||||
} else {
|
||||
getInspectorLogger().error(message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
class Logger {
|
||||
trace(...args: any[]) {
|
||||
if (shouldLog('trace')) {
|
||||
const location = getCallerLocation();
|
||||
console.debug(`[TRACE:${location}]`, ...args);
|
||||
}
|
||||
warn(...args: unknown[]) {
|
||||
const { message, error } = normalize(args);
|
||||
// inspector only logs errors; escalate to error level
|
||||
if (error) {
|
||||
getInspectorLogger().error(error);
|
||||
} else if (message) {
|
||||
getInspectorLogger().error(message);
|
||||
}
|
||||
},
|
||||
|
||||
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();
|
||||
info(...args: unknown[]) {
|
||||
const { message } = normalize(args);
|
||||
// promote to error so it passes the filter
|
||||
if (message) getInspectorLogger().error(message);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
70
src/lib/core/utils/vite-logger.ts
Normal file
70
src/lib/core/utils/vite-logger.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Custom Vite logger that routes Vite's build/dev-server messages
|
||||
* through our LogTape-powered logger for consistent formatting.
|
||||
*
|
||||
* Usage in vite.config.ts:
|
||||
* import { createViteLogger } from './src/lib/core/utils/vite-logger';
|
||||
* export default defineConfig({
|
||||
* customLogger: createViteLogger(),
|
||||
* // ...
|
||||
* });
|
||||
*/
|
||||
import type { LogOptions, LogErrorOptions } from 'vite';
|
||||
import type { Logger } from 'vite';
|
||||
import { logger } from './logger';
|
||||
|
||||
export function createViteLogger(): Logger {
|
||||
const warnedMessages = new Set<string>();
|
||||
|
||||
return {
|
||||
get hasWarned() {
|
||||
return warnedMessages.size > 0;
|
||||
},
|
||||
|
||||
info(msg: string, options?: LogOptions) {
|
||||
const prefix = options?.environment ? `[${options.environment}]` : '';
|
||||
if (prefix) {
|
||||
logger.info(`${prefix} ${msg}`);
|
||||
} else if (options?.timestamp) {
|
||||
logger.info(`[vite] ${msg}`);
|
||||
} else {
|
||||
logger.info(msg);
|
||||
}
|
||||
},
|
||||
|
||||
warn(msg: string, options?: LogOptions) {
|
||||
warnedMessages.add(msg);
|
||||
const prefix = options?.environment ? `[${options.environment}]` : '';
|
||||
if (prefix) {
|
||||
logger.warn(`${prefix} ${msg}`);
|
||||
} else {
|
||||
logger.warn(msg);
|
||||
}
|
||||
},
|
||||
|
||||
warnOnce(msg: string, options?: LogOptions) {
|
||||
if (warnedMessages.has(msg)) return;
|
||||
this.warn(msg, options);
|
||||
},
|
||||
|
||||
error(msg: string, options?: LogErrorOptions) {
|
||||
const prefix = options?.environment ? `[${options.environment}]` : '';
|
||||
const err = options?.error;
|
||||
const fullMsg = prefix ? `${prefix} ${msg}` : msg;
|
||||
if (err && err instanceof Error) {
|
||||
logger.error(fullMsg, err);
|
||||
} else {
|
||||
logger.error(fullMsg);
|
||||
}
|
||||
},
|
||||
|
||||
clearScreen() {
|
||||
// Let Vite handle screen clearing itself
|
||||
},
|
||||
|
||||
hasErrorLogged(_error: Error | import('rollup').RollupError): boolean {
|
||||
// Don't keep error state — always re-report
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
63
src/lib/workers/androidPayload.worker.ts
Normal file
63
src/lib/workers/androidPayload.worker.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/// <reference lib="webworker" />
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let expectedLength = -1;
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
if (e.data.type === 'CHUNK') {
|
||||
const chunk = e.data.payload;
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
// Loop to process complete messages
|
||||
while (true) {
|
||||
// 1. Try to get length header (need 4 bytes)
|
||||
if (expectedLength === -1 && totalBytes >= 4) {
|
||||
// Need a flat buffer just to read the header
|
||||
const headerBuf = concatChunks(chunks, 4);
|
||||
expectedLength = new DataView(headerBuf.buffer).getInt32(0, false);
|
||||
|
||||
// Remove header bytes from our chunks array
|
||||
removeBytes(4);
|
||||
}
|
||||
|
||||
// 2. Try to get payload
|
||||
if (expectedLength !== -1 && totalBytes >= expectedLength) {
|
||||
const payload = concatChunks(chunks, expectedLength);
|
||||
removeBytes(expectedLength);
|
||||
|
||||
self.postMessage({ type: 'DATA', payload }, [payload.buffer]);
|
||||
|
||||
expectedLength = -1; // Reset for next message
|
||||
} else {
|
||||
break; // Wait for more data
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper: Concatenate only what we need
|
||||
function concatChunks(chunks: Uint8Array[], length: number): Uint8Array {
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < chunks.length && offset < length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const toCopy = Math.min(chunk.length, length - offset);
|
||||
result.set(chunk.slice(0, toCopy), offset);
|
||||
// If we only used part of the chunk, we keep the rest in the array
|
||||
if (toCopy < chunk.length) {
|
||||
chunks[i] = chunk.slice(toCopy);
|
||||
} else {
|
||||
chunks.shift();
|
||||
i--;
|
||||
}
|
||||
offset += toCopy;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper: Update total count
|
||||
function removeBytes(n: number) {
|
||||
totalBytes -= n;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<!-- navbar select menus -->
|
||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
<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';
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import AdbReconnectBanner from '$lib/components/adb-reconnect-banner.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let websocketConnectedForUid = $state('');
|
||||
|
|
@ -86,7 +88,7 @@
|
|||
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
'ERR:Device is already in use by another program, please close the program and try again'
|
||||
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -144,3 +146,5 @@
|
|||
<svelte:component this={TerminalDrawerComponent} />
|
||||
{/if}
|
||||
</Sidebar.Provider>
|
||||
|
||||
<AdbReconnectBanner />
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@
|
|||
|
||||
if (refPage === 'priceslot') {
|
||||
await goto(`/sheet/priceslot/${cnt}`);
|
||||
} else if (refPage === 'price') {
|
||||
await goto(`/sheet/price/${cnt}`);
|
||||
} else if (refPage === 'sheet') {
|
||||
await goto(`/sheet/overview/${cnt}`);
|
||||
} else {
|
||||
|
|
@ -38,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');
|
||||
|
|
@ -51,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');
|
||||
|
|
|
|||
450
src/routes/(authed)/firmware/+page.svelte
Normal file
450
src/routes/(authed)/firmware/+page.svelte
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
|
||||
import {
|
||||
firmwareStore,
|
||||
FIRMWARE_COUNTRIES,
|
||||
formatFileSize,
|
||||
formatBuildDate,
|
||||
statusBadgeVariant,
|
||||
statusLabel,
|
||||
type FirmwareFile,
|
||||
type FirmwareBuildStatus
|
||||
} from '$lib/core/stores/firmwareStore';
|
||||
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
LoaderCircle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from '@lucide/svelte/icons';
|
||||
import { auth as authStore } from '$lib/core/stores/auth';
|
||||
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let selectedCountry = $state<string>(FIRMWARE_COUNTRIES[0].code);
|
||||
let buildDialogOpen = $state(false);
|
||||
let buildVersion = $state('');
|
||||
let buildNotes = $state('');
|
||||
let isRequesting = $state(false);
|
||||
|
||||
// All countries state from the store
|
||||
let allCountries = $derived($firmwareStore);
|
||||
|
||||
// Currently selected country state
|
||||
let currentCountryState = $derived(allCountries[selectedCountry] ?? null);
|
||||
|
||||
// Files for the current country
|
||||
let currentFiles = $derived(currentCountryState?.files ?? []);
|
||||
|
||||
// Is a build currently in progress for this country?
|
||||
let isBuildInProgress = $derived(
|
||||
currentCountryState?.buildStatus === 'queued' || currentCountryState?.buildStatus === 'building'
|
||||
);
|
||||
|
||||
// Options per country
|
||||
let increaseVersion: boolean = $state(false);
|
||||
let saveThisVersionBuildDate: boolean = $state(false);
|
||||
let saveThisVersionAsLatest: boolean = $state(false);
|
||||
|
||||
// Subscriptions
|
||||
let unsubscribe_fw_version: any;
|
||||
|
||||
// Latest Versions, allow refresh by re-entering page or from refresh
|
||||
let latest_versions: {
|
||||
[key: string]: {
|
||||
version: string;
|
||||
date: string;
|
||||
};
|
||||
} = $state({});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openBuildDialog() {
|
||||
// Pre-fill version with a suggestion
|
||||
buildVersion = `v1.${new Date().getMonth() + 1}.${new Date().getDate()}`;
|
||||
buildNotes = '';
|
||||
buildDialogOpen = true;
|
||||
}
|
||||
|
||||
async function confirmRequestBuild() {
|
||||
if (!buildVersion.trim()) {
|
||||
addNotification('ERR:Version is required');
|
||||
return;
|
||||
}
|
||||
|
||||
let requester = get(authStore)?.uid ?? '';
|
||||
|
||||
isRequesting = true;
|
||||
try {
|
||||
firmwareStore.requestBuild({
|
||||
country: selectedCountry,
|
||||
version: buildVersion.trim(),
|
||||
notes: buildNotes.trim() || undefined,
|
||||
requester
|
||||
});
|
||||
addNotification(`INFO:Firmware build requested for ${selectedCountry.toUpperCase()}`);
|
||||
buildDialogOpen = false;
|
||||
} catch (e) {
|
||||
addNotification('ERR:Failed to request firmware build');
|
||||
logger.error('firmware build request failed', e);
|
||||
} finally {
|
||||
isRequesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(file: FirmwareFile) {
|
||||
if (file.status !== 'ready' || !file.downloadUrl) {
|
||||
addNotification('ERR:Firmware file is not ready for download');
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, this would be a real URL or trigger a WS download.
|
||||
// For now, just notify.
|
||||
addNotification(`INFO:Downloading ${file.filename}…`);
|
||||
logger.info('download firmware', file.filename, formatFileSize(file.size));
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
firmwareStore.refreshFiles(selectedCountry);
|
||||
addNotification('INFO:Refreshed firmware list');
|
||||
}
|
||||
|
||||
function statusIcon(status: FirmwareBuildStatus) {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return CheckCircle2;
|
||||
case 'building':
|
||||
return LoaderCircle;
|
||||
case 'queued':
|
||||
return Clock;
|
||||
case 'failed':
|
||||
return AlertCircle;
|
||||
default:
|
||||
return HardDrive;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
logger.info('firmware page mounted');
|
||||
unsubscribe_fw_version = GlobalEventBus.on('firmware-versions', (data: any) => {
|
||||
latest_versions = data;
|
||||
logger.info(`firmware versions: ${JSON.stringify(latest_versions)}`);
|
||||
});
|
||||
|
||||
sendMessage({
|
||||
type: 'firmware-versions',
|
||||
payload: {}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe_fw_version?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-8 flex flex-col gap-6 p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<ArchiveIcon class="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Firmware Requests</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Request and download firmware builds per country
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={handleRefresh} disabled={isBuildInProgress}>
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onclick={openBuildDialog} disabled={isBuildInProgress}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Request Build
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Country Tabs + Content -->
|
||||
<Tabs.Root bind:value={selectedCountry}>
|
||||
<Tabs.List class="flex w-full flex-wrap gap-1">
|
||||
{#each FIRMWARE_COUNTRIES as country (country.code)}
|
||||
<Tabs.Trigger value={country.code} class="flex-1">
|
||||
{country.label}
|
||||
{#if allCountries[country.code]?.buildStatus === 'building'}
|
||||
<LoaderCircle class="ml-1 h-3 w-3 animate-spin" />
|
||||
{:else if allCountries[country.code]?.buildStatus === 'ready'}
|
||||
<CheckCircle2 class="ml-1 h-3 w-3 text-emerald-500" />
|
||||
{/if}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
{#each FIRMWARE_COUNTRIES as country (country.code)}
|
||||
<Tabs.Content value={country.code}>
|
||||
{@render countryPanel(country.code)}
|
||||
</Tabs.Content>
|
||||
{/each}
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<!-- Country Panel Snippet -->
|
||||
{#snippet countryPanel(countryCode: string)}
|
||||
{@const state = allCountries[countryCode]}
|
||||
{@const files = state?.files ?? []}
|
||||
{@const buildInProgress = state?.buildStatus === 'queued' || state?.buildStatus === 'building'}
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Build Status Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
{#if buildInProgress}
|
||||
<LoaderCircle class="h-5 w-5 animate-spin text-blue-500" />
|
||||
{:else if state?.buildStatus === 'ready'}
|
||||
<CheckCircle2 class="h-5 w-5 text-emerald-500" />
|
||||
{:else if state?.buildStatus === 'failed'}
|
||||
<AlertCircle class="h-5 w-5 text-red-500" />
|
||||
{:else}
|
||||
<HardDrive class="h-5 w-5 text-muted-foreground" />
|
||||
{/if}
|
||||
{state?.label ?? countryCode}
|
||||
</Card.Title>
|
||||
<Card.Description>Firmware build status and available files</Card.Description>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(state?.buildStatus ?? 'idle')}>
|
||||
{statusLabel(state?.buildStatus ?? 'idle')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if buildInProgress}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Building firmware…</span>
|
||||
<span class="font-mono text-xs text-muted-foreground"
|
||||
>~{state?.files[0]?.version ?? ''}</span
|
||||
>
|
||||
</div>
|
||||
<Progress value={50} class="h-2" />
|
||||
</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<ArchiveIcon class="h-12 w-12 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">No firmware builds yet</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Click "Request Build" to create a new firmware for {state?.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- File Table -->
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[40%]">Filename</Table.Head>
|
||||
<Table.Head class="w-[15%]">Version</Table.Head>
|
||||
<Table.Head class="w-[15%]">Size</Table.Head>
|
||||
<Table.Head class="w-[15%]">Built</Table.Head>
|
||||
<Table.Head class="w-[10%]">Status</Table.Head>
|
||||
<Table.Head class="w-[5%] text-right">Download</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each files as file (file.id)}
|
||||
{@const StatusIcon = statusIcon(file.status)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-sm">
|
||||
{file.filename}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground">
|
||||
{file.version}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-sm">
|
||||
{formatFileSize(file.size)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs text-muted-foreground">
|
||||
{formatBuildDate(file.builtAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
class="h-3.5 w-3.5 {file.status === 'building'
|
||||
? 'animate-spin text-blue-500'
|
||||
: file.status === 'ready'
|
||||
? 'text-emerald-500'
|
||||
: file.status === 'failed'
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
<span class="text-xs">{statusLabel(file.status)}</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if file.status === 'ready' && file.downloadUrl}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => handleDownload(file)}
|
||||
title="Download"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
{:else if file.status === 'building'}
|
||||
<Spinner class="scale-75" />
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground/50">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Request Build Dialog -->
|
||||
<Dialog.Root bind:open={buildDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Request Firmware Build</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Request a new firmware build for
|
||||
<span class="font-semibold">{allCountries[selectedCountry]?.label ?? selectedCountry}</span
|
||||
>. You will be notified when the build is ready or need confirmation.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="fw-version">Latest Firmware Version</Label>
|
||||
<Input
|
||||
id="fw-version"
|
||||
bind:value={latest_versions[selectedCountry].version}
|
||||
placeholder="10.00"
|
||||
class="font-mono"
|
||||
disabled={true}
|
||||
/>
|
||||
{#if latest_versions[selectedCountry].version == 'NO_DATA'}
|
||||
<p class="font-sans text-[12px]">
|
||||
Note: this will use latest version provided from source.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="fw-date">Latest Firmware Release Date</Label>
|
||||
<Input
|
||||
id="fw-date"
|
||||
bind:value={latest_versions[selectedCountry].date}
|
||||
placeholder="..."
|
||||
class="font-mono"
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
{#if latest_versions[selectedCountry].date == 'NO_DATA'}
|
||||
<p class="font-sans text-[12px]">Note: this will forcefully save today as build date.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- -->
|
||||
<div class="grid gap-2">
|
||||
<!-- <Label for="fw-version">Firmware Version</Label>
|
||||
<Input id="fw-version" bind:value={buildVersion} placeholder="v1.2.3" class="font-mono" /> -->
|
||||
<Label>
|
||||
<Checkbox
|
||||
id="ch-set-new-version"
|
||||
checked={increaseVersion}
|
||||
onchange={(e) => {
|
||||
const checkbox = e.target as HTMLInputElement | null;
|
||||
if (checkbox) {
|
||||
increaseVersion = checkbox.checked;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Set to new version (Optional)</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<!-- <Label for="fw-notes">Notes (optional)</Label>
|
||||
<Input id="fw-notes" bind:value={buildNotes} placeholder="Bug fixes, new features, etc." /> -->
|
||||
<Label>
|
||||
<Checkbox
|
||||
id="ch-save-new-version-bdate"
|
||||
checked={saveThisVersionBuildDate}
|
||||
onchange={(e) => {
|
||||
const checkbox = e.target as HTMLInputElement | null;
|
||||
if (checkbox) {
|
||||
saveThisVersionBuildDate = checkbox.checked;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Save this version's build date (Optional)</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<!-- <Label for="fw-notes">Notes (optional)</Label>
|
||||
<Input id="fw-notes" bind:value={buildNotes} placeholder="Bug fixes, new features, etc." /> -->
|
||||
<Label>
|
||||
<Checkbox
|
||||
id="ch-save-new-version"
|
||||
checked={saveThisVersionAsLatest}
|
||||
onchange={(e) => {
|
||||
const checkbox = e.target as HTMLInputElement | null;
|
||||
if (checkbox) {
|
||||
saveThisVersionAsLatest = checkbox.checked;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Save this version as latest (Optional)</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (buildDialogOpen = false)}>Cancel</Button>
|
||||
<Button onclick={confirmRequestBuild} disabled={isRequesting || !buildVersion.trim()}>
|
||||
{#if isRequesting}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Requesting…
|
||||
{:else}
|
||||
Request Build
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
|
@ -11,7 +11,16 @@
|
|||
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;
|
||||
|
|
@ -76,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());
|
||||
|
|
@ -109,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();
|
||||
|
|
@ -122,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
|
||||
);
|
||||
|
|
@ -146,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,
|
||||
|
|
@ -197,6 +294,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMachineCountryShort() {
|
||||
const content = await pullTextWithRetry(machineCountryShortPath, 5000, 1);
|
||||
return normalizeCountryShort(content ?? '');
|
||||
}
|
||||
|
||||
async function connectAdb() {
|
||||
try {
|
||||
if (!adb.getAdbInstance()) {
|
||||
|
|
@ -204,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()) {
|
||||
|
|
@ -218,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;
|
||||
|
|
@ -244,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',
|
||||
|
|
@ -331,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 '';
|
||||
|
|
@ -502,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));
|
||||
|
|
@ -533,7 +752,10 @@
|
|||
|
||||
onMount(() => {
|
||||
referenceFromPage.set('topping');
|
||||
if (adb.getAdbInstance()) void loadRecipeFromMachine();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribePermission();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -553,18 +775,92 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
|
||||
<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 if devRecipe}
|
||||
Reload From Android
|
||||
{:else}
|
||||
Connect & Load
|
||||
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">
|
||||
|
|
@ -593,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>
|
||||
|
|
@ -617,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>
|
||||
|
|
@ -631,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>
|
||||
|
|
@ -651,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>
|
||||
|
|
@ -664,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>
|
||||
|
|
@ -684,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>
|
||||
|
|
@ -695,35 +996,54 @@
|
|||
</div>
|
||||
<div class="grid max-h-[70vh] overflow-auto">
|
||||
{#each filteredToppingGroups as group}
|
||||
<div class="border-b">
|
||||
<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"
|
||||
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="font-mono font-medium text-primary">{group.groupID}</span>
|
||||
<span class="font-medium">{group.name || '-'}</span>
|
||||
<span class="text-muted-foreground">{group.otherName || '-'}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{group.idInGroup || '-'}</span
|
||||
<span class="font-mono text-xs text-muted-foreground"
|
||||
>{group.idInGroup || '-'}</span
|
||||
>
|
||||
<span
|
||||
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !== false
|
||||
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
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
loadGroupIntoForm(group);
|
||||
}}
|
||||
disabled={!canWriteToAndroid}>Edit</Button
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => openDeleteConfirm('group', group)}
|
||||
disabled={saving}
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDeleteConfirm('group', group);
|
||||
}}
|
||||
disabled={saving || !canWriteToAndroid}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -731,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]">
|
||||
|
|
@ -758,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">
|
||||
|
|
@ -929,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>
|
||||
|
|
@ -957,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]">
|
||||
|
|
@ -980,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">
|
||||
|
|
@ -1004,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>
|
||||
|
|
@ -1032,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}
|
||||
|
|
@ -1064,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>
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@
|
|||
Eye,
|
||||
Cog,
|
||||
Upload,
|
||||
RotateCcw
|
||||
RotateCcw,
|
||||
Usb,
|
||||
MonitorSmartphone
|
||||
} from '@lucide/svelte/icons';
|
||||
|
||||
import * as adb from '$lib/core/adb/adb';
|
||||
|
|
@ -345,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;
|
||||
|
|
@ -526,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]) => [
|
||||
|
|
@ -539,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',
|
||||
|
|
@ -597,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}`;
|
||||
|
|
@ -628,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;
|
||||
}
|
||||
|
||||
|
|
@ -1170,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');
|
||||
|
|
@ -1201,9 +1328,9 @@
|
|||
try {
|
||||
for (let i = 0; i < filesToPush.length; i++) {
|
||||
const file = filesToPush[i];
|
||||
const filename = file.file.split('/').pop() || '';
|
||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
||||
|
||||
const androidPath = genFileToMachinePath(file.file, country);
|
||||
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||
await adb.push(androidPath, file.content);
|
||||
pushProgress = { current: i + 1, total: filesToPush.length };
|
||||
|
||||
|
|
@ -1237,9 +1364,9 @@
|
|||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const filename = file.file.split('/').pop() || '';
|
||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
||||
|
||||
const androidPath = genFileToMachinePath(file.file, country);
|
||||
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||
await adb.push(androidPath, file.content);
|
||||
pushProgress = { current: i + 1, total: files.length };
|
||||
|
||||
|
|
@ -1471,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);
|
||||
|
|
@ -2078,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>
|
||||
|
|
@ -2093,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)}
|
||||
|
|
@ -2112,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>
|
||||
|
|
@ -2127,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)}
|
||||
|
|
@ -2323,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
287
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
287
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<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';
|
||||
import GenPricePanel from '$lib/components/gen-price-panel.svelte';
|
||||
|
||||
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>
|
||||
|
||||
{#if selectedCountry}
|
||||
<div class="mb-5">
|
||||
<GenPricePanel country={selectedCountry} mode="price" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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
File diff suppressed because it is too large
Load diff
|
|
@ -32,7 +32,13 @@
|
|||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter';
|
||||
import {
|
||||
adbWriter,
|
||||
isAdbWriterAlive,
|
||||
wasAndroidSocketEverAlive,
|
||||
sendToAndroid
|
||||
} from '$lib/core/stores/adbWriter';
|
||||
import { adbReconnect } from '$lib/core/stores/adbReconnectStore';
|
||||
import { AdbInstance } from '../../../state.svelte';
|
||||
import {
|
||||
setOnMenuSavedCallback,
|
||||
|
|
@ -68,7 +74,7 @@
|
|||
let recipeLoading = $state(false);
|
||||
let recipeAutoLoadAttempted = $state(false);
|
||||
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||||
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
|
||||
let isAndroidSocketConnected = $derived(isAdbWriterAlive());
|
||||
let isRecipeLoaded = $derived(Boolean(devRecipe));
|
||||
|
||||
// clear out event
|
||||
|
|
@ -121,6 +127,7 @@
|
|||
try {
|
||||
addNotification('WARN:Load recipe from app memories ...');
|
||||
|
||||
logger.info('sending request get recipe');
|
||||
sendToAndroid({
|
||||
type: 'get_recipe',
|
||||
payload: {}
|
||||
|
|
@ -207,7 +214,7 @@
|
|||
async function connectAdb() {
|
||||
try {
|
||||
if (adb.getAdbInstance()) {
|
||||
if (!isAdbWriterAvailable()) {
|
||||
if (!isAdbWriterAlive()) {
|
||||
await adb.reconnectAndroidServer();
|
||||
}
|
||||
await loadBrewDataFromConnectedAdb();
|
||||
|
|
@ -225,7 +232,14 @@
|
|||
await loadBrewDataFromConnectedAdb();
|
||||
}
|
||||
} catch (e: any) {
|
||||
addNotification(`ERROR:${e}`);
|
||||
// addNotification(`ERROR:${e}`);
|
||||
GlobalEventBus.emit('announce', {
|
||||
title: 'Device is busy',
|
||||
subtitle: 'Expect `adb` or some related program has been using the device.',
|
||||
message: 'Help: kill the process & try again',
|
||||
buttonText: 'Ok',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +253,7 @@
|
|||
async function tryAutoConnect() {
|
||||
try {
|
||||
if (adb.getAdbInstance()) {
|
||||
if (!isAdbWriterAvailable()) {
|
||||
if (!isAdbWriterAlive()) {
|
||||
await adb.reconnectAndroidServer();
|
||||
}
|
||||
return true;
|
||||
|
|
@ -272,7 +286,7 @@
|
|||
}
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
addNotification(
|
||||
'ERR:Device is already in use by another program, please close the program and try again'
|
||||
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -334,7 +348,8 @@
|
|||
}
|
||||
|
||||
async function ensureAndroidSocket() {
|
||||
if (isAdbWriterAvailable()) return true;
|
||||
// Use the liveness check, not just "is object in store"
|
||||
if (isAdbWriterAlive()) return true;
|
||||
|
||||
if (!adb.getAdbInstance()) {
|
||||
addNotification('ERR:ADB is not connected');
|
||||
|
|
@ -343,7 +358,7 @@
|
|||
|
||||
await adb.reconnectAndroidServer();
|
||||
|
||||
if (!isAdbWriterAvailable()) {
|
||||
if (!isAdbWriterAlive()) {
|
||||
addNotification('ERR:Android socket is not connected');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -353,7 +368,9 @@
|
|||
|
||||
afterNavigate(async () => {
|
||||
logger.info('after navigate brew');
|
||||
await startFetchRecipeFromMachine();
|
||||
// Data loading is handled by the $effect below — no need to
|
||||
// duplicate it here. This hook is kept for future navigation-
|
||||
// specific side effects only.
|
||||
});
|
||||
|
||||
function getMenuStatus(ms: number): RecipeOverview['status'] {
|
||||
|
|
@ -832,69 +849,45 @@
|
|||
onDestroy(() => {
|
||||
unsubRecipeEvent();
|
||||
clearOnMenuSavedCallback();
|
||||
void adb.goToMachineHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isAdbConnected) {
|
||||
recipeAutoLoadAttempted = false;
|
||||
// Don't reset recipeAutoLoadAttempted here — that causes
|
||||
// duplicate loads on back-and-forth navigation.
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
|
||||
|
||||
recipeAutoLoadAttempted = true;
|
||||
void loadBrewDataFromConnectedAdb();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const brewAppStatusInterval = setInterval(async () => {
|
||||
// schedule status from .brew_web_status.log
|
||||
let inst = adb.getAdbInstance();
|
||||
if (inst && devRecipe && !recipeLoading) {
|
||||
await adb.executeCmd(
|
||||
'tail -n 1 /sdcard/coffeevending/.brew_web_status.log > /sdcard/coffeevending/.brew_web_status.latest.log'
|
||||
);
|
||||
|
||||
const latestStatusPath = env.PUBLIC_BREW_WEB_LATEST_STATUS;
|
||||
if (!latestStatusPath) return;
|
||||
|
||||
let brew_status_log = await adb.pull(latestStatusPath);
|
||||
if (brew_status_log) {
|
||||
let latest_log = brew_status_log;
|
||||
|
||||
if (brew_status !== latest_log) {
|
||||
brew_status = latest_log;
|
||||
|
||||
// noti from machine
|
||||
if (latest_log.includes('!!!')) {
|
||||
let log = latest_log.split('!!!');
|
||||
let noti_cfg = log[1];
|
||||
|
||||
if (noti_cfg.includes('=')) {
|
||||
let noti_level = noti_cfg.split('=')[1];
|
||||
|
||||
let spl = log[0].split(':');
|
||||
let pure_msg = spl[spl.length - 1];
|
||||
|
||||
// case special message
|
||||
if (pure_msg.includes('starting retry process')) {
|
||||
// is waiting/idle process
|
||||
pure_msg = 'Wait for brewing';
|
||||
void (async () => {
|
||||
if (!isAdbWriterAlive()) {
|
||||
// Writer is null or stale. Decide what to do:
|
||||
if (wasAndroidSocketEverAlive()) {
|
||||
// Socket was alive before and died → show reconnect
|
||||
// banner. isAdbWriterAlive() already triggered it
|
||||
// if the writer was stale.
|
||||
adbReconnect.requestReconnect('Android socket disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
addNotification(`${noti_level}:${pure_msg}`);
|
||||
// First visit — auto-connect the Android server.
|
||||
if (adb.getAdbInstance()) {
|
||||
try {
|
||||
await adb.reconnectAndroidServer();
|
||||
} catch {
|
||||
// Connection failed — user can retry manually.
|
||||
}
|
||||
}
|
||||
// Re-check after connect attempt
|
||||
if (!isAdbWriterAlive()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (inst && !devRecipe) {
|
||||
// try again
|
||||
// await startFetchRecipeFromMachine();
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(brewAppStatusInterval);
|
||||
};
|
||||
await loadBrewDataFromConnectedAdb();
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { adbWriter, isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
|
||||
import { adbWriter, isAdbWriterAlive, wasAndroidSocketEverAlive } from '$lib/core/stores/adbWriter';
|
||||
import { AdbInstance } from '../../../state.svelte';
|
||||
import {
|
||||
categoryOptions,
|
||||
|
|
@ -36,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();
|
||||
|
|
@ -51,11 +54,14 @@
|
|||
// 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));
|
||||
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
|
||||
let isAndroidSocketConnected = $derived(isAdbWriterAlive());
|
||||
let isRecipeLoaded = $derived(Boolean(devRecipe));
|
||||
|
||||
// Setup popup state
|
||||
|
|
@ -83,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 = {
|
||||
|
|
@ -92,8 +106,6 @@
|
|||
otherName: string;
|
||||
description: string;
|
||||
otherDescription: string;
|
||||
cashPrice: string;
|
||||
nonCashPrice: string;
|
||||
image: string;
|
||||
isUse: boolean;
|
||||
recipeSteps: any[];
|
||||
|
|
@ -123,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 ?? [])
|
||||
|
|
@ -165,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,
|
||||
|
|
@ -218,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;
|
||||
|
|
@ -235,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);
|
||||
|
|
@ -298,16 +523,40 @@
|
|||
try {
|
||||
const connected = await adb.ensureAdbConnection();
|
||||
if (connected) {
|
||||
if (!isAdbWriterAvailable()) {
|
||||
if (!isAdbWriterAlive()) {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
}
|
||||
await loadRecipeFromMachine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('usb' in navigator)) {
|
||||
throw new Error('WebUSB not supported');
|
||||
}
|
||||
|
||||
await adb.connectRecipeMenuViaWebUSB();
|
||||
if (adb.getAdbInstance()) {
|
||||
await loadRecipeFromMachine();
|
||||
}
|
||||
} catch (e: any) {
|
||||
addNotification(`ERROR:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectAndroidSocket() {
|
||||
try {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
if (isAdbWriterAlive()) {
|
||||
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;
|
||||
|
||||
|
|
@ -357,7 +606,12 @@
|
|||
// No country file means Thailand
|
||||
detectedCountry = 'THAI';
|
||||
}
|
||||
logger.info('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
|
||||
logger.info(
|
||||
'[CreateMenu] Detected country:',
|
||||
detectedCountry,
|
||||
'-> prefix:',
|
||||
detectedCountryCode
|
||||
);
|
||||
} catch (error) {
|
||||
// Error reading file means Thailand (default)
|
||||
detectedCountry = 'THAI';
|
||||
|
|
@ -368,11 +622,11 @@
|
|||
}
|
||||
|
||||
async function ensureAndroidSocket(): Promise<boolean> {
|
||||
if (isAdbWriterAvailable()) return true;
|
||||
if (isAdbWriterAlive()) return true;
|
||||
|
||||
try {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
if (isAdbWriterAvailable()) return true;
|
||||
if (isAdbWriterAlive()) return true;
|
||||
} catch {}
|
||||
|
||||
addNotification('WARN:Android socket not connected');
|
||||
|
|
@ -548,8 +802,6 @@
|
|||
otherName: '',
|
||||
description: '',
|
||||
otherDescription: '',
|
||||
cashPrice: '0',
|
||||
nonCashPrice: '0',
|
||||
image: '',
|
||||
isUse: false,
|
||||
recipeSteps: [createEmptyRecipeStep()],
|
||||
|
|
@ -735,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();
|
||||
|
|
@ -744,8 +1005,9 @@
|
|||
if (!adb.getAdbInstance()) return;
|
||||
|
||||
try {
|
||||
const tempPath = `${stagedMenuAndroidPath}.tmp`;
|
||||
await adb.push(
|
||||
stagedMenuAndroidPath,
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
|
|
@ -756,6 +1018,8 @@
|
|||
2
|
||||
)
|
||||
);
|
||||
const result = await adb.executeCmd(`mv ${tempPath} ${stagedMenuAndroidPath}`);
|
||||
if (result?.error) throw new Error(String(result.error));
|
||||
} catch (error) {
|
||||
logger.error('failed to persist staged menus to Android', error);
|
||||
addNotification('WARN:Failed to save draft menus to Android');
|
||||
|
|
@ -804,21 +1068,21 @@
|
|||
}
|
||||
|
||||
function buildMenuFromForm(form: TempFormData) {
|
||||
const toppingSet = form.toppingOptions
|
||||
.filter((opt) => opt.slot != null && opt.groupID != null)
|
||||
.map((opt) => {
|
||||
const toppingSet = Array.from({ length: toppingSetTargetCount }, () => createEmptyToppingSet());
|
||||
for (const opt of form.toppingOptions) {
|
||||
if (opt.slot == null || opt.groupID == null) continue;
|
||||
|
||||
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);
|
||||
return {
|
||||
toppingSet[index] = {
|
||||
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
|
||||
defaultIDSelect: opt.defaultIDSelect ?? 0,
|
||||
groupID: String(opt.groupID),
|
||||
isUse: true
|
||||
};
|
||||
});
|
||||
|
||||
while (toppingSet.length < 4) {
|
||||
toppingSet.push(createEmptyToppingSet());
|
||||
}
|
||||
|
||||
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
|
||||
|
|
@ -833,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,
|
||||
|
|
@ -850,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,
|
||||
|
|
@ -863,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');
|
||||
|
|
@ -908,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);
|
||||
|
||||
|
|
@ -920,7 +1188,7 @@
|
|||
type: 'save_recipe_menu_file',
|
||||
payload: {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: menu
|
||||
data: buildPendingOnlineMenu(menu)
|
||||
}
|
||||
});
|
||||
if (!sent) {
|
||||
|
|
@ -954,18 +1222,6 @@
|
|||
addNotification('WARN:No draft menus ready to save');
|
||||
return;
|
||||
}
|
||||
const invalidMenus = menus.filter((menu) => getActiveRecipeStepCount(menu) === 0);
|
||||
if (invalidMenus.length > 0) {
|
||||
for (const menu of invalidMenus) {
|
||||
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
|
||||
}
|
||||
addNotification(
|
||||
`ERR:Select at least one material before saving: ${invalidMenus
|
||||
.map((menu) => menu.productCode)
|
||||
.join(', ')}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
'[Create Menu] save all draft menus',
|
||||
menus.map((menu) => menu.productCode)
|
||||
|
|
@ -979,7 +1235,7 @@
|
|||
type: 'save_recipe_menu_file_batch',
|
||||
payload: {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
data: menus
|
||||
data: menus.map(buildPendingOnlineMenu)
|
||||
}
|
||||
});
|
||||
if (!sent) {
|
||||
|
|
@ -1126,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
|
||||
|
|
@ -1142,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()],
|
||||
|
|
@ -1184,12 +1439,16 @@
|
|||
|
||||
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
|
||||
$effect(() => {
|
||||
if (!isAdbConnected) {
|
||||
recipeAutoLoadAttempted = false;
|
||||
// Don't reset recipeAutoLoadAttempted — prevents duplicate loads
|
||||
// on back-and-forth navigation.
|
||||
detectedCountry = '';
|
||||
return;
|
||||
}
|
||||
|
|
@ -1200,8 +1459,23 @@
|
|||
|
||||
// Auto-load recipe data and country
|
||||
(async () => {
|
||||
if (!isAdbWriterAvailable()) {
|
||||
if (!isAdbWriterAlive()) {
|
||||
if (wasAndroidSocketEverAlive()) {
|
||||
// Socket was alive before and died → reconnect banner
|
||||
// already triggered by isAdbWriterAlive() if stale.
|
||||
return;
|
||||
}
|
||||
// First visit — auto-connect the Android recipe menu server.
|
||||
if (adb.getAdbInstance()) {
|
||||
try {
|
||||
await adb.reconnectAndroidRecipeMenuServer();
|
||||
} catch {
|
||||
// Connection failed — user can retry manually.
|
||||
}
|
||||
}
|
||||
if (!isAdbWriterAlive()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await loadCountryFromMachine();
|
||||
await loadStagedMenusFromAndroid();
|
||||
|
|
@ -1237,11 +1511,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}>
|
||||
|
|
@ -1249,6 +1518,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">
|
||||
|
|
@ -1373,7 +1662,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}
|
||||
|
||||
|
|
@ -1479,11 +1771,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>
|
||||
|
|
@ -1499,10 +1791,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)}
|
||||
/>
|
||||
|
|
@ -1517,28 +1812,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
|
||||
|
|
@ -1604,23 +1878,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">
|
||||
|
|
@ -1758,51 +2023,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>
|
||||
|
|
@ -1823,6 +2074,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">
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@
|
|||
files = [];
|
||||
}
|
||||
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
const token = await $auth?.getIdToken?.();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
const currentUser = $auth;
|
||||
if (!currentUser) {
|
||||
|
|
@ -159,6 +165,7 @@
|
|||
|
||||
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
body: formData
|
||||
});
|
||||
|
||||
|
|
|
|||
1136
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
1136
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
47
src/routes/api/adv-file/+server.ts
Normal file
47
src/routes/api/adv-file/+server.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { 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;
|
||||
|
||||
// Stream a single adv .mp4 back to the browser. The frontend fetches this with a
|
||||
// Bearer token and turns the response into a Blob/object URL for a <video> tag
|
||||
// (adv videos live only on the FTP server, so they can't be linked directly).
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
try {
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const country = url.searchParams.get('country');
|
||||
const filename = url.searchParams.get('filename');
|
||||
if (!country || !filename) {
|
||||
throw error(400, 'Missing country or filename');
|
||||
}
|
||||
|
||||
// POST (not GET): the kong gateway only routes POST to the taobin-image service.
|
||||
const endpoint = `${ADV_API_BASE}/adv/file/${encodeURIComponent(country)}/${encodeURIComponent(filename)}`;
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw error(response.status, errorData.detail || 'Failed to fetch adv video');
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'video/mp4',
|
||||
'Content-Length': response.headers.get('Content-Length') || '',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[Adv File Proxy] Error:', err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||
}
|
||||
};
|
||||
36
src/routes/api/adv-list/+server.ts
Normal file
36
src/routes/api/adv-list/+server.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
|
||||
// List the adv .mp4 files currently on a country's FTP folder (for preview).
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const { country } = await request.json();
|
||||
if (!country || typeof country !== 'string') {
|
||||
throw error(400, 'Missing country');
|
||||
}
|
||||
|
||||
const endpoint = `${ADV_API_BASE}/adv/list/${encodeURIComponent(country)}`;
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw error(response.status, errorData.detail || 'Failed to list adv videos');
|
||||
}
|
||||
|
||||
return json(await response.json());
|
||||
} catch (err) {
|
||||
logger.error('[Adv 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');
|
||||
}
|
||||
};
|
||||
46
src/routes/api/adv-thumb/+server.ts
Normal file
46
src/routes/api/adv-thumb/+server.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { logger } from '$lib/core/utils/logger';
|
||||
import { 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;
|
||||
|
||||
// Return a small JPEG poster (single frame) for an adv video so the slot grid can
|
||||
// show a thumbnail without downloading the whole mp4. The frontend fetches this
|
||||
// with a Bearer token and turns the response into a Blob/object URL for an <img>.
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
try {
|
||||
await verifyAuthToken(request);
|
||||
|
||||
const country = url.searchParams.get('country');
|
||||
const filename = url.searchParams.get('filename');
|
||||
if (!country || !filename) {
|
||||
throw error(400, 'Missing country or filename');
|
||||
}
|
||||
|
||||
// POST (not GET): the kong gateway only routes POST to the taobin-image service.
|
||||
const endpoint = `${ADV_API_BASE}/adv/thumb/${encodeURIComponent(country)}/${encodeURIComponent(filename)}`;
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw error(response.status, errorData.detail || 'Failed to fetch thumbnail');
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'image/jpeg',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[Adv Thumb Proxy] Error:', err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||
}
|
||||
};
|
||||
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');
|
||||
}
|
||||
};
|
||||
63
src/routes/api/catalog-create/+server.ts
Normal file
63
src/routes/api/catalog-create/+server.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {logger} from '$lib/core/utils/logger'
|
||||
|
||||
// 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)}`;
|
||||
|
||||
logger.debug('[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');
|
||||
}
|
||||
};
|
||||
38
src/routes/api/scrcpy-server/+server.ts
Normal file
38
src/routes/api/scrcpy-server/+server.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* GET /api/scrcpy-server
|
||||
*
|
||||
* Proxies the scrcpy-server binary from GitHub releases.
|
||||
* Fixes CORS issues (GitHub doesn't send Access-Control-Allow-Origin for binaries).
|
||||
* Server-side fetch has no CORS restrictions.
|
||||
*
|
||||
* Response is cached by the browser for 24 hours via Cache-Control header.
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const GITHUB_URL =
|
||||
'https://github.com/Genymobile/scrcpy/releases/download/v2.3/scrcpy-server-v2.3';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const response = await fetch(GITHUB_URL);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[scrcpy-server] GitHub returned ${response.status}`);
|
||||
return new Response(`Failed to fetch scrcpy-server: ${response.status}`, {
|
||||
status: 502
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the response directly — don't buffer the entire binary in memory on the server
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400, immutable'
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[scrcpy-server] Fetch failed:', e);
|
||||
return new Response('Failed to fetch scrcpy-server from GitHub', { status: 500 });
|
||||
}
|
||||
};
|
||||
|
|
@ -38,7 +38,8 @@ export async function POST({ request }: RequestEvent) {
|
|||
|
||||
// Initialize or update batch
|
||||
if (!streamCache.has(batch_id)) {
|
||||
const cappedTotal = typeof total_chunks === 'number' && total_chunks > 0
|
||||
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, {
|
||||
|
|
@ -63,7 +64,9 @@ export async function POST({ request }: RequestEvent) {
|
|||
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
|
||||
} else if (msg === 'end') {
|
||||
batch.status = 'complete';
|
||||
logger.info(`[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;
|
||||
|
|
|
|||
81
src/routes/api/video-mainpage/+server.ts
Normal file
81
src/routes/api/video-mainpage/+server.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {logger} from '$lib/core/utils/logger'
|
||||
|
||||
// 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)}`;
|
||||
|
||||
logger.debug('[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');
|
||||
}
|
||||
};
|
||||
32
src/routes/api/video-mainpage/testmode/+server.ts
Normal file
32
src/routes/api/video-mainpage/testmode/+server.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||
|
||||
// Return the country's web video scripts with a TestMode date/time override
|
||||
// injected (or removed). The caller pushes the result to a machine over ADB —
|
||||
// nothing is committed or synced. POST because the Kong route is POST-only.
|
||||
export const POST: RequestHandler = async ({ url, request }) => {
|
||||
try {
|
||||
const country = (url.searchParams.get('country') || 'tha').toLowerCase();
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const response = await fetch(
|
||||
`${API_BASE}/video/mainpage/testmode/${encodeURIComponent(country)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const e = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw error(response.status, e.detail || 'Test-mode apply failed');
|
||||
}
|
||||
return json(await response.json());
|
||||
} catch (err) {
|
||||
console.error('[Video MainPage TestMode 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM", "WebWorker"]
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import devtoolsJson from 'vite-plugin-devtools-json';
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { createViteLogger } from './src/lib/core/utils/vite-logger';
|
||||
|
||||
export default defineConfig({
|
||||
customLogger: createViteLogger(),
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
ssr: {
|
||||
noExternal: ['@dnd-kit/core', '@dnd-kit/sortable']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue