This commit is contained in:
pakintada@gmail.com 2026-02-17 14:30:02 +07:00
commit 451223816b
338 changed files with 9938 additions and 0 deletions

View file

@ -0,0 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<h1>{page.status} {page.error?.message}</h1>

View file

@ -0,0 +1,14 @@
import { auth } from '$lib/core/stores/auth.js';
import { departmentStore } from '$lib/core/stores/departments.ts';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
export async function load({ cookies, url }) {
if (!cookies.get('logged_in')) {
redirect(303, `/login?redirectTo=${url.pathname}`);
}
if (url.pathname.includes('recipe') && !cookies.get('department')) {
redirect(303, `/departments`);
}
}

View file

@ -0,0 +1,30 @@
<!-- navbar select menus -->
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import AppAccountSelect from '$lib/components/app-account-select.svelte';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import '../layout.css';
import ErrorLayout from '$lib/components/error-layout.svelte';
import { sidebarStore } from '$lib/core/stores/sidebar';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex" rel="stylesheet" />
<title>Taobin Management Tools</title>
</svelte:head>
<Sidebar.Provider
onOpenChange={(open) => {
sidebarStore.set(open);
}}
>
<AppSidebar />
<main class="h-screen w-screen overflow-hidden">
<Sidebar.Trigger />
{@render children()}
</main>
</Sidebar.Provider>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import Dashboard from "$lib/components/dashboard.svelte";
</script>
<Dashboard/>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex" rel="stylesheet" />
<title>Taobin Management Tools</title>
</svelte:head>
{@render children()}

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { asset } from '$app/paths';
import { permission as currentPerms } from '$lib/core/stores/permissions';
import { get } from 'svelte/store';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { departmentStore } from '$lib/core/stores/departments';
import { socketStore } from '$lib/core/stores/websocketStore';
import { goto } from '$app/navigation';
import { addNotification } from '$lib/core/stores/noti';
import { browser } from '$app/environment';
import { setCookieOnNonBrowser } from '$lib/helpers/cookie';
let enabledAccessibleCountries: string[] = $state([]);
function onCountrySelected(cnt: string) {
departmentStore.set(cnt);
if (browser && 'cookieStore' in window) cookieStore.set('department', cnt);
else setCookieOnNonBrowser('department', cnt);
addNotification(`INFO:Selected ${cnt}`);
setTimeout(async () => {
console.log(get(departmentStore));
await goto('/recipe/overview');
}, 1000);
}
// read or write permission
let userCurrentPerms = get(currentPerms).filter(
(x) => x.startsWith('document.read') || x.startsWith('document.write')
);
// show country by enabled `document.read.{country}`
enabledAccessibleCountries = userCurrentPerms
.filter((x) => x.startsWith('document.read'))
.map((x) => x.split('.')[2]);
// update every 30s
if (enabledAccessibleCountries.length == 0) {
setTimeout(() => {
// read or write permission
let userCurrentPerms = get(currentPerms).filter(
(x) => x.startsWith('document.read') || x.startsWith('document.write')
);
// show country by enabled `document.read.{country}`
enabledAccessibleCountries = userCurrentPerms
.filter((x) => x.startsWith('document.read'))
.map((x) => x.split('.')[2]);
}, 1000);
}
</script>
<h1 class="m-8 text-4xl font-bold">Country Selection</h1>
<p class="m-8 text-muted-foreground">Select country to view/edit recipe</p>
{#if enabledAccessibleCountries.length == 0}
<div class="flex h-max w-max items-center justify-center">
<Spinner class="size-48" />
</div>
{:else}
<div class="m-8 grid grid-flow-dense grid-cols-5 gap-4">
{#each enabledAccessibleCountries as country}
<div>
<button class="hover:cursor-pointer" onclick={() => onCountrySelected(country)}>
<img src={asset(`/departments/logo/${country}_plate.png`)} alt={country} loading="lazy" />
</button>
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,14 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex" rel="stylesheet" />
<title>Taobin Management Tools</title>
</svelte:head>
{@render children()}

View file

@ -0,0 +1,138 @@
<script lang="ts">
import RecipeModuleBtn from "$lib/assets/modules/recipe_btn.png";
import MachineInspectBtn from "$lib/assets/modules/monitoring_btn.png";
import {animate, JSAnimation, remove as removeAnime, stagger} from "animejs";
import { goto } from "$app/navigation";
import Button from "$lib/components/ui/button/button.svelte";
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import { permission as currentPermissions } from "$lib/core/stores/permissions";
import { get } from "svelte/store";
let recipeModBtn = $state<HTMLElement | null>(null);
let monitorModBtn = $state<HTMLElement | null>(null);
let gotoDashboardBtn = $state<HTMLElement | null>(null);
let animationPulseGoto: JSAnimation;
setInterval(() => {
if(gotoDashboardBtn && !animationPulseGoto){
animationPulseGoto = animate(gotoDashboardBtn, {
opacity: [0.8, 1], // Slight pulse in opacity
duration: 800, // Duration of one pulse
loop: true, // Repeat forever
alternate: true,
delay: stagger(100),
ease: 'inOutQuad',
boxShadow: [
'0 0 0px 0 none',
'0 0 0px 7px lightblue',
],
});
}
}, 1000);
let perms = get(currentPermissions);
</script>
<div class="flex h-screen items-center justify-center">
<div class="columns-md place-content-center gap-8">
<h1 class="text-center font-bold text-4xl m-8">Module Selection</h1>
<div class="flex justify-between items-center">
<!-- need permission `document` -->
{#if perms.filter((x) => x.startsWith("document.read") || x.startsWith("document.write")).length > 0}
<button
class="button"
id="recipe_mod_btn"
bind:this={recipeModBtn}
onclick={() => goto('/departments')}
onmouseenter={() => {
if(recipeModBtn){
animate(recipeModBtn, {
scale: 1.1,
duration: 300,
ease: "inOutSine"
});
}
}}
onmouseleave={() => {
if(recipeModBtn){
animate(recipeModBtn, {
scale: 1.0,
duration: 200,
ease: "inOutSine"
});
}
}}>
<img
src={RecipeModuleBtn}
alt="Recipes"
loading="lazy"
/>
</button>
{/if}
<!-- need permission `tools` -->
{#if perms.filter((x) => x.startsWith("tools")).length > 0}
<button
class="button"
id="monitor_mod_btn"
bind:this={monitorModBtn}
onclick={() => goto('/tools')}
onmouseenter={() => {
if(monitorModBtn){
animate(monitorModBtn, {
scale: 1.1,
duration: 300,
ease: "inOutSine"
});
}
}}
onmouseleave={() => {
if(monitorModBtn){
animate(monitorModBtn, {
scale: 1.0,
duration: 200,
ease: "inOutSine"
});
}
}}>
<img
src={MachineInspectBtn}
alt="Recipes"
loading="lazy"
/>
</button>
{/if}
</div>
{#if perms.filter((x) => x.startsWith("document.read") || x.startsWith("document.write") || x.startsWith("tools")).length == 0}
<p class="text-center text-2xl m-8">No modules are available.
Please check your account with admin.</p>
{/if}
<div class="flex justify-center items-center m-16">
<button
id="goto_dash_btn"
class="focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-primary text-primary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 rounded-full h-9 px-4 py-2 has-[>svg]:px-3"
bind:this={gotoDashboardBtn}
onclick={() => goto('/dashboard')}
>
Go to Dashboard <ArrowRight size={32}/>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,14 @@
import { getRecipes } from '$lib/core/client/server';
import { recipeData } from '$lib/core/stores/recipeStore';
import { get } from 'svelte/store';
export async function load({ cookies, params }) {
let dep = cookies.get('department');
console.log('load recipe ', dep);
let recipes = await getRecipes();
recipes = get(recipeData);
return {
recipes
};
}

View file

@ -0,0 +1,63 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { SearchIcon } from '@lucide/svelte/icons';
import DataTable from './data-table.svelte';
import { columns, type RecipeOverview } from './columns';
import { onDestroy, onMount } from 'svelte';
import { loadRecipe, recipeData } from '$lib/core/stores/recipeStore.js';
import { sendMessage } from '$lib/core/handlers/ws_messageSender.js';
import { auth } from '$lib/core/stores/auth.js';
import { get } from 'svelte/store';
import { getRecipes } from '$lib/core/client/server.js';
let data: { recipes: RecipeOverview[] } = $state({
recipes: []
});
let unsubRecipeData = recipeData.subscribe((rd) => {
if (rd) {
data.recipes = rd == null ? [] : rd;
}
});
onMount(async () => {
// do load recipe
// loadRecipe();
await getRecipes();
});
onDestroy(() => {
unsubRecipeData();
});
</script>
<div class="mx-8 flex">
<!-- header -->
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Overview</h1>
<p class="mx-8 my-0 text-muted-foreground">
Display menus from the current selected country
</p>
</div>
<div class="mx-8 my-4 flex gap-2">
<Button variant="default">+ Create Menu</Button>
</div>
</div>
<!-- search bar -->
<!-- <div class="mx-4 my-8 flex w-full items-center justify-center gap-2">
<SearchIcon />
<Input type="text" placeholder="Search by id, product code, name or material" class="" />
</div> -->
<!-- filter -->
<!-- table -->
<div class="w-full overflow-auto">
<DataTable data={data.recipes} refPage="overview" {columns} />
</div>
</div>
</div>

View file

@ -0,0 +1,92 @@
import { renderComponent } from '$lib/components/ui/data-table';
import type { ColumnDef } from '@tanstack/table-core';
import DataTableTagsBadge from './data-table-tags-badge.svelte';
import DataTableHeader from './data-table-header.svelte';
import type { FilterFn } from '@tanstack/table-core';
import { rankItem } from '@tanstack/match-sorter-utils';
import DataTableActions from './data-table-actions.svelte';
import { get } from 'svelte/store';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
};
export type RecipeOverview = {
productCode: string;
name: string;
description: string;
tags: string;
status: 'ready' | 'obsolete' | 'drafted' | 'pending/online' | 'pending/offline';
};
export const columns: ColumnDef<RecipeOverview>[] = [
{
accessorKey: 'productCode',
header: ({ column }) =>
renderComponent(DataTableHeader, {
onclick: column.getToggleSortingHandler(),
data: 'Product Code'
}),
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'name',
header: ({ column }) =>
renderComponent(DataTableHeader, {
onclick: column.getToggleSortingHandler(),
data: 'Name'
}),
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'description',
header: ({ column }) =>
renderComponent(DataTableHeader, {
onclick: column.getToggleSortingHandler(),
data: 'Description'
}),
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'tags',
header: ({ column }) =>
renderComponent(DataTableHeader, {
onclick: column.getToggleSortingHandler(),
data: 'Tags'
}),
cell: ({ row }) => {
return renderComponent(DataTableTagsBadge, { tags: row.original.tags });
},
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'status',
header: ({ column }) =>
renderComponent(DataTableHeader, {
onclick: column.getToggleSortingHandler(),
data: 'Status'
}),
cell: ({ row }) => {
return renderComponent(DataTableTagsBadge, { tags: row.original.status });
},
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
id: 'actions',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
refPage: get(referenceFromPage),
...row.original
});
}
}
];

View file

@ -0,0 +1,57 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import { EllipsisIcon } from '@lucide/svelte/icons';
import type { RecipeOverview } from './columns';
import Button from '$lib/components/ui/button/button.svelte';
import { onMount } from 'svelte';
import Separator from '$lib/components/ui/separator/separator.svelte';
import RecipeEditorDialog from '$lib/components/recipe-editor-dialog.svelte';
let {
productCode = '',
name = '',
description = '',
tags = '',
status,
refPage
}: RecipeOverview & { refPage: string } = $props();
let dataForCopy = $state('');
onMount(() => {
dataForCopy = `${productCode}\t${name}\t${description}\t${tags}\t${status ?? 'drafted'}`;
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<EllipsisIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<!-- open dialog need permission: recipe edit tool brew-->
{#if refPage !== ''}
<DropdownMenu.Item onclick={(e) => e.preventDefault()}>
<RecipeEditorDialog {productCode} {refPage} />
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item>
<p class="text-muted-foreground">Cannot Edit</p>
</DropdownMenu.Item>
{/if}
<Separator />
<DropdownMenu.Group>
<DropdownMenu.Label>Actions</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => navigator.clipboard.writeText(dataForCopy)}
>Copy For Sheets</DropdownMenu.Item
>
<!-- note: require permission level dev -->
<DropdownMenu.Item onclick={() => navigator.clipboard.writeText(dataForCopy)}
>Export</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -0,0 +1 @@
// may not use

View file

@ -0,0 +1,16 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { ArrowUpDownIcon } from '@lucide/svelte';
import type { ComponentProps } from 'svelte';
let {
variant = 'ghost',
data,
...restProps
}: ComponentProps<typeof Button> & { data: string } = $props();
</script>
<Button {variant} {...restProps}>
{data}
<ArrowUpDownIcon class="ms-2" />
</Button>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import { type BadgeVariant } from '$lib/components/ui/badge/badge.svelte';
import { BadgeCheckIcon } from '@lucide/svelte/icons';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { onMount } from 'svelte';
let { tags }: { tags: string } = $props();
let tagList: string[] = $state([]);
let extendTags: string[] = $state([]);
function getVariantByKeyword(tag: string): BadgeVariant {
if (tag === 'ready') {
return 'default';
} else if (tag === 'drafted') {
return 'secondary';
} else if (tag === 'obsolete') {
return 'destructive';
} else {
return 'default';
}
}
function getClassByKeyword(tag: string): string {
switch (tag) {
case 'ready':
return 'bg-green-500 text-white dark:bg-green-600';
case 'pending/online':
return 'bg-yellow-500 text-black dark:bg-yellow-600';
default:
return '';
}
}
onMount(() => {
// build tags
if (tags) {
tagList = tags.split(',');
// do check if too long tags
if (tagList.length > 2) {
let into_ext = tagList.splice(2);
extendTags = into_ext;
}
}
});
</script>
<div class="flex w-full flex-wrap gap-2">
{#each tagList as tag}
<Badge variant={getVariantByKeyword(tag)} class={getClassByKeyword(tag)}>
{#if tag === 'ready'}
<BadgeCheckIcon />
{/if}
{tag}
</Badge>
{/each}
<!-- requre permission: view mat extend -->
{#if extendTags.length > 0}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Badge id="tags-ext-count" variant="secondary">
{extendTags.length}+
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
{extendTags.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
</div>

View file

@ -0,0 +1,159 @@
<script lang="ts" generics="TData, TValue">
import {
type ColumnDef,
type ColumnFiltersState,
type FilterFn,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type GlobalFilterTableState,
type PaginationState,
type SortingState
} from '@tanstack/table-core';
import { rankItem } from '@tanstack/match-sorter-utils';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index';
import * as Table from '$lib/components/ui/table/index';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { SearchIcon } from '@lucide/svelte/icons';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
};
let { data, columns, refPage }: DataTableProps<TData, TValue> & { refPage: string } = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
let sorting = $state<SortingState>([]);
let columnFilter = $state<ColumnFiltersState>([]);
let globalFilter = $state<GlobalFilterTableState>();
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
};
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
get sorting() {
return sorting;
},
get globalFilter() {
return globalFilter;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
fuzzy: fuzzyFilter
},
globalFilterFn: fuzzyFilter,
onSortingChange: (updater) => {
if (typeof updater === 'function') {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onPaginationChange: (updater) => {
if (typeof updater === 'function') {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === 'function') {
columnFilter = updater(columnFilter);
} else {
columnFilter = updater;
}
},
onGlobalFilterChange: (updater) => {
if (typeof updater === 'function') {
globalFilter = updater(globalFilter);
} else {
globalFilter = updater;
}
}
});
</script>
<div>
<div class="flex items-center gap-2 py-4">
<SearchIcon />
<Input
type="text"
placeholder="Search by id, product code, name or material"
onchange={(e) => {
table.setGlobalFilter(e.currentTarget.value);
}}
oninput={(e) => {
table.setGlobalFilter(e.currentTarget.value);
}}
/>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && 'selected'}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">No results.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mx-4 flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>Previous</Button
>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>Next</Button
>
</div>
</div>
</div>

View file

@ -0,0 +1,352 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { onMount } from 'svelte';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
import {
materialFromMachineQuery,
recipeFromMachine,
recipeFromMachineLoading,
recipeFromMachineQuery,
referenceFromPage
} from '$lib/core/stores/recipeStore';
import DataTable from '../../recipe/overview/data-table.svelte';
import { handleIncomingMessages } from '$lib/core/handlers/messageHandler';
import { auth as authStore } from '$lib/core/stores/auth';
import { machineInfoStore } from '$lib/core/stores/machineInfoStore';
import { get } from 'svelte/store';
import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
const sourceDir = '/sdcard/coffeevending';
// fetched recipe
let devRecipe: any | undefined = $state();
// data to display
let data: { recipes: RecipeOverview[] } = $state({
recipes: []
});
async function startFetchRecipeFromMachine() {
let instance = adb.getAdbInstance();
recipeFromMachineLoading.set(true);
referenceFromPage.set('brew');
if (instance) {
let dev_recipe = await adb.pull(`${sourceDir}/cfg/recipe_branch_dev.json`);
if (dev_recipe) {
if (dev_recipe.length == 0) {
// case error, do last retry
dev_recipe = await adb.pull(`${sourceDir}/coffeethai02.json`);
if (dev_recipe && dev_recipe.length == 0)
addNotification('ERROR:Cannot fetch recipe from machine');
else if (dev_recipe) {
// From coffeethai02
devRecipe = JSON.parse(dev_recipe);
recipeFromMachineLoading.set(false);
addNotification('INFO:Fetch recipe success!');
buildOverviewForBrewing();
}
} else {
// from recipe_branch_dev
devRecipe = JSON.parse(dev_recipe);
recipeFromMachineLoading.set(false);
addNotification('INFO:Fetch recipe success!');
buildOverviewForBrewing();
}
}
} else {
addNotification('ERROR:Cannot connect to machine');
recipeFromMachineLoading.set(false);
}
}
async function loadEssentialFiles() {
let instance = adb.getAdbInstance();
if (instance) {
// check country
let country = await adb.pull('/sdcard/coffeevending/country/short');
// check dev
let devMode = await adb.pull('/sdcard/coffeevending/CURR_TEST');
// check .bid
let boxid = await adb.pull('/sdcard/coffeevending/.bid');
machineInfoStore.set({
boxId: boxid,
versions: {
firmware: '',
brew: '',
xmlengine: '',
netcore: '',
devbox: ''
},
devMode: devMode?.includes('1') ?? false,
country: country ?? '',
status: '',
errors: []
});
handleIncomingMessages(
JSON.stringify({
type: 'chat',
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
})
);
} else {
addNotification('ERROR:Failed to get machine info');
}
}
async function connectAdb() {
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback or different browser');
}
await adb.connnectViaWebUSB();
let instance = adb.getAdbInstance();
if (instance) {
await startFetchRecipeFromMachine();
await loadEssentialFiles();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
}
}
async function tryAutoConnect() {
try {
if (!('usb' in navigator) || !AdbDaemonWebUsbDeviceManager.BROWSER) {
throw new Error('WebUSB not supported, try using fallback or different browser');
}
const devices = await AdbDaemonWebUsbDeviceManager.BROWSER.getDevices();
if (!devices || devices.length == 0) {
throw new Error('No device found');
}
if (devices.length > 1) {
throw new Error('Too many connected devices');
}
const device = devices[0];
const credStore = new AdbWebCredentialStore();
try {
await adb.connectDeviceByCred(device, credStore);
return true;
} catch (e: any) {
if (e.message === 'CREDENTIAL_EXPIRED') {
try {
await deviceCredentialManager.clearAllCredentials();
} catch (ignored) {}
}
return false;
}
} catch (e) {
console.error('error on auto connect brew page', e);
addNotification('ERROR:Failed to auto connect, please try again');
}
}
onMount(async () => {
// do auto connect
if (!adb.getAdbInstance()) await tryAutoConnect();
await startFetchRecipeFromMachine();
});
function getMenuStatus(ms: number): RecipeOverview['status'] {
switch (ms) {
case 0:
return 'ready';
case 2:
return 'obsolete';
case 11:
return 'pending/online';
case 12:
return 'pending/offline';
default:
return 'drafted';
}
}
function getMenuCategory(pd: string): string {
// [country_code]-[category_code]-[drink_Type]-[id]
let pd_spl = pd.split('-');
let category = pd_spl[1] ?? '';
if (category) {
if (category.endsWith('1')) {
let result = 'coffee';
if (category === '01') {
result += ',v1';
} else {
result += ',v2+';
}
return result;
} else if (category.endsWith('2')) {
return 'tea';
} else if (category.endsWith('3')) {
return 'milk';
} else if (category.endsWith('4')) {
return 'whey';
} else if (category.endsWith('5')) {
return 'soda';
} else if (category == '99') {
return 'special';
}
}
return 'unknown';
}
function getDrinkType(pd: string): string {
// [country_code]-[category_code]-[drink_Type]-[id]
let pd_spl = pd.split('-');
let drink_type = pd_spl[2] ?? '';
if (drink_type) {
if (drink_type.endsWith('1')) {
return 'hot';
} else if (drink_type.endsWith('2')) {
return 'cold / iced';
} else if (drink_type.endsWith('3')) {
return 'smoothie / frappe';
}
}
return '';
}
// set material used in recipe to tags for using in filter
function getMainMaterialOfRecipe(rp: any): string {
let recipeList = rp['recipes'] ?? [];
let mat_lists = '';
for (let rpl of recipeList) {
let mat_id = rpl['materialPathId'];
let mat_in_use = rpl['isUse'];
if (mat_in_use && !mat_lists.includes(mat_id)) {
mat_lists += mat_id + ',';
}
}
mat_lists = mat_lists.substring(0, mat_lists.length - 1);
return mat_lists;
}
function buildTags(rp: any): string {
let result = '';
result += getMenuCategory(rp['productCode']);
let dt = getDrinkType(rp['productCode']);
let mats = getMainMaterialOfRecipe(rp);
if (dt !== '') result += ',' + dt;
if (mats !== '') result += ',' + mats;
return result;
}
function buildOverviewForBrewing() {
if (devRecipe) {
let recipe01_query: any = {};
recipeFromMachine.set(devRecipe);
data.recipes = [];
for (let rp of devRecipe['Recipe01']) {
data.recipes.push({
productCode: rp['productCode'] ?? '<not set>',
name: rp['name'] ? rp['name'] : (rp['otherName'] ?? '<not set>'),
description: rp['desciption']
? rp['desciption']
: (rp['otherDescription'] ?? '<not set>'),
tags: buildTags(rp),
status: getMenuStatus(rp['MenuStatus'])
});
recipe01_query[rp['productCode']] = rp;
if (rp['SubMenu'] && rp['SubMenu'].length > 0) {
for (let rps of rp['SubMenu']) {
data.recipes.push({
productCode: rps['productCode'] ?? '<not set>',
name: rps['name'] ? rps['name'] : (rps['otherName'] ?? '<not set>'),
description: rps['desciption']
? rps['desciption']
: (rps['otherDescription'] ?? '<not set>'),
tags: buildTags(rps),
status: getMenuStatus(rps['MenuStatus'])
});
recipe01_query[rps['productCode']] = rps;
}
}
}
let materialFromMachine = devRecipe['MaterialSetting'];
let currentQuery = get(recipeFromMachineQuery);
currentQuery = {
recipe: recipe01_query,
...currentQuery
};
let currentMaterialsQuery = materialFromMachine;
currentQuery = {
material: currentMaterialsQuery,
...currentQuery
};
recipeFromMachineQuery.set(currentQuery);
materialFromMachineQuery.set(currentMaterialsQuery);
}
}
</script>
<div class="mx-8 flex">
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Brew</h1>
<p class="mx-8 my-0 text-muted-foreground">Brewing directly from web to machine</p>
<p class="mx-8 my-0 text-muted-foreground">
Note: refreshing page may cut connection with machine
</p>
</div>
<div class="mx-8 my-4 flex gap-2">
{#if !adb.getAdbInstance()}
<Button variant="default" onclick={() => connectAdb()}>Connect</Button>
{:else}
<Button variant="default">+ Create Menu</Button>
{/if}
</div>
<!-- <DashboardQuickAdb enableComponent={true} /> -->
</div>
<!-- search bar -->
<div class="w-full">
{#if $recipeFromMachineLoading}
<div class="flex items-center justify-center">
<p class="mx-4">Please wait</p>
<Spinner />
</div>
{:else}
<DataTable data={data.recipes} refPage="brew" {columns} />
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
</script>
<div class="mx-8 flex">
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Debug Toolkits</h1>
<p class="mx-8 my-0 text-muted-foreground">Tools for hotfix machine or viewing infos</p>
</div>
<div class="mx-8 my-4 flex gap-2">
<Button variant="default">Help</Button>
</div>
</div>
</div>
</div>

5
src/routes/+error.svelte Normal file
View file

@ -0,0 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<h1>{page.status} {page.error?.message}</h1>

81
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,81 @@
<!-- This is common file and will render through all pages -->
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { AdbInstance } from './state.svelte';
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
import { onMount } from 'svelte';
import { onAuthStateChanged } from 'firebase/auth';
import { auth as authStore } from '$lib/core/stores/auth';
import { auth } from '$lib/core/client/firebase';
import { goto } from '$app/navigation';
import { getUserPermission } from '$lib/core/auth/userPermissions';
import { permission as currentPermissions } from '$lib/core/stores/permissions';
import { get } from 'svelte/store';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from '$lib/components/ui/sonner';
import { departmentStore } from '$lib/core/stores/departments';
import { browser } from '$app/environment';
import {
deleteCookiesOnNonBrowser,
extractCookieOnNonBrowser,
setCookieOnNonBrowser
} from '$lib/helpers/cookie';
let { children } = $props();
onMount(() => {
console.log('base url', window.location.origin, document.cookie);
onAuthStateChanged(auth, async function (s) {
authStore.set(s);
if (s) {
if (browser && 'cookieStore' in window) await cookieStore.set('logged_in', 'true');
else {
setCookieOnNonBrowser('logged_in', 'true');
}
} else {
if (browser && 'cookieStore' in window) await cookieStore.delete('logged_in');
else {
deleteCookiesOnNonBrowser('logged_in');
}
await goto('/login');
}
});
return authStore.subscribe(async function (user) {
// console.log(`store get ${JSON.stringify(user)}`);
// reloading permissions
if (get(currentPermissions).length == 0 && user != null) {
// need update
let currentUser = user;
currentPermissions.set(await getUserPermission(currentUser));
console.log('reloading permissions ... ');
}
if (!get(departmentStore)) {
let saved =
browser && 'cookieStore' in window
? await cookieStore.get('department')
: extractCookieOnNonBrowser()['department'];
if (saved) {
departmentStore.set(saved.value);
console.log('get dep', get(departmentStore));
}
}
});
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex" rel="stylesheet" />
<title>Taobin Management Tools</title>
</svelte:head>
<ModeWatcher />
<Toaster />
{@render children()}

93
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,93 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import StatusHealth from '$lib/assets/status-health.svelte';
import * as adb from '$lib/core/adb/adb';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { TerminalComponent } from '$lib/components/ui/terminal';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/core/client/firebase';
import { auth as authStore } from '$lib/core/stores/auth';
// const device = await usb.requestDevice({
// filters: [
// {
// vendorId: 0x18d1
// }
// ]
// });
let recipe: any | undefined = undefined;
async function test_adb() {
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback');
}
await adb.connnectViaWebUSB();
let instance = adb.getAdbInstance();
if (instance) {
console.log('create instance ok');
let result = await adb.executeCmd(
'am start -n com.forthvending.coffeemain/com.forthvending.coffeemain.MainActivity'
);
console.log(result);
}
} catch (e) {
console.error(e);
}
}
async function test_send_command() {
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback');
}
let instance = adb.getAdbInstance();
if (instance) {
let txt = document.getElementById('cmd-input') as HTMLInputElement;
console.log('instance existed, ', txt.value);
let result = await adb.executeCmd(txt.value ?? '');
console.log(result);
}
} catch (e) {
console.error(e);
}
}
async function test_pull_recipe_dev() {
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback');
}
let instance = adb.getAdbInstance();
if (instance) {
let result = await adb.pull('/sdcard/coffeevending/cfg/recipe_branch_dev.json');
let payload = JSON.parse(result ?? '');
console.log(payload);
recipe = payload;
alert('pull completed!');
}
} catch (e) {
console.error(`[PULL] ${e}`);
}
}
</script>
<!--
<button
class="bg-white px-4 py-2 border flex gap-2 border-slate-200 rounded-lg text-slate-700 hover:border-slate-400 hover:text-slate-900 hover:shadow transition duration-150"
onclick={logout}
>
<span>Logout</span>
</button> -->

68
src/routes/layout.css Normal file
View file

@ -0,0 +1,68 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { asset } from '$app/paths';
import TaobinLogo from '$lib/assets/logo.svelte';
import {
browserSessionPersistence,
GoogleAuthProvider,
setPersistence,
signInWithPopup
} from 'firebase/auth';
import { auth } from '$lib/core/client/firebase';
import { auth as authStore } from '$lib/core/stores/auth';
import { checkAllowAccess } from '$lib/core/auth/domainBlocker';
import { fade } from 'svelte/transition';
import { getUserPermission } from '$lib/core/auth/userPermissions';
import { permission } from '$lib/core/stores/permissions';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
const fullUrl = $derived(page.url.href);
const searchParams = $derived(page.url.searchParams);
const queryRedirect = $derived(searchParams.get('redirectTo'));
let loading = $state(false);
// function loginWithGoogle() {
// const returnUrl = $page.url.searchParams.get("redirectUrl") || '/';
// window.location.href = `${env.PUBLIC_API_URL}/auth/google?redirect_to=${returnUrl}`
// }
const signInWithGoogle = async () => {
try {
loading = true;
const result = await signInWithPopup(auth, new GoogleAuthProvider());
const userDomain = result.user.email?.split('@')[1];
if (!(await checkAllowAccess(userDomain ?? ''))) {
throw new Error('Unallowed Access:' + JSON.stringify(result));
} else {
authStore.set(result.user);
let current_perms = await getUserPermission(result.user);
permission.set(current_perms);
loading = false;
// create session
const idToken = await result.user.getIdToken(true);
// await fetch('/sessionLogin', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify({ idToken }),
// credentials: 'include'
// });
//
console.log('login success!');
goto('/entry');
}
} catch (error: any) {
console.error(error);
// TODO:
authStore.set(null);
await auth.signOut();
await auth.updateCurrentUser(null);
loading = false;
await goto(fullUrl);
alert('Unexpected account\n' + error);
}
};
</script>
<main
class="flex h-screen flex-col justify-around bg-[#eae6e1]"
transition:fade={{ duration: 1000 }}
>
{#if loading}
<div class="bg-black-100 flex h-screen items-center justify-center">
<Spinner class="size-56" />
</div>
{:else}
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img
class="mx-auto h-50 w-auto"
src={asset('/logo.png')}
alt="Tao Bin | Forth Vanding Machine"
/>
<h2 class="mt-10 text-center text-2xl leading-9 font-bold tracking-tight text-gray-900">
Sign in with your @Forth account
</h2>
<div class="mt-10 flex justify-center sm:mx-auto sm:w-full sm:max-w-sm">
<!-- <div id="signin_google" #googleLoginButton></div> -->
<button
class="flex gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-slate-700 transition duration-150 hover:border-slate-400 hover:text-slate-900 hover:shadow"
onclick={signInWithGoogle}
>
<img
class="h-6 w-6"
src={asset('/google-color.svg')}
alt="google logo"
loading="lazy"
/>
<span>Login with @forth.co.th Google account</span>
</button>
</div>
</div>
</div>
{/if}
</main>

View file

@ -0,0 +1,13 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View file

@ -0,0 +1,8 @@
import { Adb } from '@yume-chan/adb';
export interface AdbInstanceInterface {
instance?: Adb;
}
export const AdbInstance: AdbInstanceInterface = $state({ instance: undefined });

View file

@ -0,0 +1,78 @@
<div class="grid h-dvh w-dvw grid-cols-4 grid-rows-4 gap-4">
<div
class="row-start-1 row-end-5 m-2 w-full rounded-3xl border border-zinc-950/25 bg-zinc-950/50 p-4 backdrop-blur-md"
id="top_left_adb"
>
<Card.Root id="adb-connect-card" style="background: #BDBDBD;">
<Card.Header>
<Card.Title>
<div class="flex gap-4">
<StatusHealth />
<h1 class="status-head">Status:</h1>
{#if adb.getAdbInstance() != null}
<p>Connected</p>
{:else}
<p>Disconnected</p>
{/if}
</div>
</Card.Title>
<Card.Action>
<Button variant="default">?</Button>
</Card.Action>
</Card.Header>
<Card.Content>
<!-- row -->
<!-- dropbox + connect btn -->
<Button class="button" onclick={test_adb}>Test ADB</Button>
<Button class="button" onclick={adb.disconnect}>Disconnect</Button>
<!-- checkbox fallback -->
<Label
class="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent/50 has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox
id="toggle-fallback-protocol"
class="data-[state=checked]:border-blue-600 data-[state=checked]:bg-blue-600 data-[state=checked]:text-white dark:data-[state=checked]:border-blue-700 dark:data-[state=checked]:bg-blue-700"
/>
<div class="grid gap-1.5 font-normal">
<p class="text-sm leading-none font-medium">Use fallback protocol</p>
<p class="text-sm text-muted-foreground">
Enable this may require download desktop agent app. Please use only unable to connect.
</p>
</div>
</Label>
</Card.Content>
</Card.Root>
<Button variant="outline" onclick={test_pull_recipe_dev}>Test Pull Recipe dev</Button>
</div>
<!-- <input id="cmd-input" class="border rounded-b-2xl" type="text"/>
<Button onclick={test_send_command}>Send cmd</Button> -->
<TerminalComponent/>
</div>
<style>
.status-head {
font-family: 'Roboto Flex';
font-weight: 700;
font-size: 12;
color: white;
}
</style>