init
This commit is contained in:
commit
451223816b
338 changed files with 9938 additions and 0 deletions
5
src/routes/(authed)/+error.svelte
Normal file
5
src/routes/(authed)/+error.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<h1>{page.status} {page.error?.message}</h1>
|
||||
14
src/routes/(authed)/+layout.server.ts
Normal file
14
src/routes/(authed)/+layout.server.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
30
src/routes/(authed)/+layout.svelte
Normal file
30
src/routes/(authed)/+layout.svelte
Normal 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>
|
||||
8
src/routes/(authed)/dashboard/+page.svelte
Normal file
8
src/routes/(authed)/dashboard/+page.svelte
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import Dashboard from "$lib/components/dashboard.svelte";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<Dashboard/>
|
||||
14
src/routes/(authed)/departments/+layout.svelte
Normal file
14
src/routes/(authed)/departments/+layout.svelte
Normal 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()}
|
||||
67
src/routes/(authed)/departments/+page.svelte
Normal file
67
src/routes/(authed)/departments/+page.svelte
Normal 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}
|
||||
14
src/routes/(authed)/entry/+layout.svelte
Normal file
14
src/routes/(authed)/entry/+layout.svelte
Normal 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()}
|
||||
138
src/routes/(authed)/entry/+page.svelte
Normal file
138
src/routes/(authed)/entry/+page.svelte
Normal 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>
|
||||
0
src/routes/(authed)/recipe/material/+page.svelte
Normal file
0
src/routes/(authed)/recipe/material/+page.svelte
Normal file
14
src/routes/(authed)/recipe/overview/+page.server.ts
Normal file
14
src/routes/(authed)/recipe/overview/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
63
src/routes/(authed)/recipe/overview/+page.svelte
Normal file
63
src/routes/(authed)/recipe/overview/+page.svelte
Normal 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>
|
||||
92
src/routes/(authed)/recipe/overview/columns.ts
Normal file
92
src/routes/(authed)/recipe/overview/columns.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
// may not use
|
||||
16
src/routes/(authed)/recipe/overview/data-table-header.svelte
Normal file
16
src/routes/(authed)/recipe/overview/data-table-header.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
159
src/routes/(authed)/recipe/overview/data-table.svelte
Normal file
159
src/routes/(authed)/recipe/overview/data-table.svelte
Normal 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>
|
||||
0
src/routes/(authed)/recipe/topping/+page.svelte
Normal file
0
src/routes/(authed)/recipe/topping/+page.svelte
Normal file
352
src/routes/(authed)/tools/brew/+page.svelte
Normal file
352
src/routes/(authed)/tools/brew/+page.svelte
Normal 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>
|
||||
17
src/routes/(authed)/tools/debug/+page.svelte
Normal file
17
src/routes/(authed)/tools/debug/+page.svelte
Normal 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
5
src/routes/+error.svelte
Normal 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
81
src/routes/+layout.svelte
Normal 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
93
src/routes/+page.svelte
Normal 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
68
src/routes/layout.css
Normal 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);
|
||||
}
|
||||
115
src/routes/login/+page.svelte
Normal file
115
src/routes/login/+page.svelte
Normal 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>
|
||||
13
src/routes/page.svelte.spec.ts
Normal file
13
src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
8
src/routes/state.svelte.ts
Normal file
8
src/routes/state.svelte.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import { Adb } from '@yume-chan/adb';
|
||||
|
||||
export interface AdbInstanceInterface {
|
||||
instance?: Adb;
|
||||
}
|
||||
|
||||
export const AdbInstance: AdbInstanceInterface = $state({ instance: undefined });
|
||||
78
src/routes/test_dev/scrap.txt
Normal file
78
src/routes/test_dev/scrap.txt
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue