init
This commit is contained in:
179
src/app.css
Normal file
179
src/app.css
Normal file
@ -0,0 +1,179 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
|
||||
--color-sidebar: hsl(var(--sidebar-background));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
14
src/hooks.client.ts
Normal file
14
src/hooks.client.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { ClientInit } from '@sveltejs/kit';
|
||||
import { SafeArea } from '@capacitor-community/safe-area';
|
||||
|
||||
export const init: ClientInit = async () => {
|
||||
await SafeArea.enable({
|
||||
config: {
|
||||
customColorsForSystemBars: true,
|
||||
statusBarColor: '#00000000', // transparent
|
||||
statusBarContent: 'light',
|
||||
navigationBarColor: '#00000000', // transparent
|
||||
navigationBarContent: 'light',
|
||||
},
|
||||
});
|
||||
};
|
76
src/lib/ScanCapture.svelte
Normal file
76
src/lib/ScanCapture.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
|
||||
let {
|
||||
focused = $bindable(false),
|
||||
scanning = $bindable(false),
|
||||
onscan
|
||||
}: {
|
||||
focused?: boolean;
|
||||
scanning?: boolean;
|
||||
onscan: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
// Data for manual inputs
|
||||
let inputElement: HTMLInputElement | null = null;
|
||||
let unfocusTimeout: NodeJS.Timeout;
|
||||
|
||||
async function onKeydown(event: KeyboardEvent) {
|
||||
// If we this is a manual input
|
||||
if (focused) {
|
||||
// Auto unfocus after 5 seconds of inactivity
|
||||
clearTimeout(unfocusTimeout);
|
||||
unfocusTimeout = setTimeout(() => {
|
||||
inputElement?.blur();
|
||||
}, 10000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an indicator that scanning has been initiated
|
||||
if (event.key == 'Unidentified') {
|
||||
scanning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the key to onscan
|
||||
if ((event.key == 'Tab' || event.key == 'Enter' || event.key == 'Backspace' || event.key == "Home" || event.key == "Delete")) {
|
||||
// Prevent tab or enter from doing anything
|
||||
event.preventDefault();
|
||||
|
||||
// Send key as text
|
||||
onscan(event.key);
|
||||
} else if (event.key.length == 1) {
|
||||
onscan(event.key);
|
||||
}
|
||||
|
||||
Haptics.impact({ style: ImpactStyle.Light });
|
||||
}
|
||||
|
||||
async function onKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Unidentified' && scanning) {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function focusIn(e: any) {
|
||||
if (e.target?.nodeName == 'INPUT') {
|
||||
focused = true;
|
||||
inputElement = e.target;
|
||||
Haptics.selectionStart();
|
||||
}
|
||||
}
|
||||
|
||||
function focusOut() {
|
||||
focused = false;
|
||||
inputElement = null;
|
||||
Haptics.selectionEnd();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={onKeydown}
|
||||
on:keyup={onKeyup}
|
||||
on:focusin={focusIn}
|
||||
on:focusout={focusOut}
|
||||
/>
|
74
src/lib/components/ui/button/button.svelte
Normal file
74
src/lib/components/ui/button/button.svelte
Normal file
@ -0,0 +1,74 @@
|
||||
<script lang="ts" module>
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{href}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
17
src/lib/components/ui/button/index.ts
Normal file
17
src/lib/components/ui/button/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import X from "lucide-svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
19
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
19
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...restProps}
|
||||
/>
|
37
src/lib/components/ui/dialog/index.ts
Normal file
37
src/lib/components/ui/dialog/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Trigger = DialogPrimitive.Trigger;
|
||||
const Close = DialogPrimitive.Close;
|
||||
const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
22
src/lib/components/ui/input/input.svelte
Normal file
22
src/lib/components/ui/input/input.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLInputAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
19
src/lib/components/ui/label/label.svelte
Normal file
19
src/lib/components/ui/label/label.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
1
src/lib/components/ui/sonner/index.ts
Normal file
1
src/lib/components/ui/sonner/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
19
src/lib/components/ui/sonner/sonner.svelte
Normal file
19
src/lib/components/ui/sonner/sonner.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||
|
||||
let restProps: SonnerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={"light"}
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
17
src/lib/cutils.ts
Normal file
17
src/lib/cutils.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Filesystem } from "@capacitor/filesystem";
|
||||
import type { GetUriOptions, MkdirOptions } from "@capacitor/filesystem";
|
||||
|
||||
export async function checkFileExists(getUriOptions: GetUriOptions): Promise<boolean> {
|
||||
try {
|
||||
await Filesystem.stat(getUriOptions);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDirectory(mkdirOptions: MkdirOptions) {
|
||||
try {
|
||||
await Filesystem.mkdir(mkdirOptions);
|
||||
} catch (_) { }
|
||||
}
|
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
70
src/lib/utils.ts
Normal file
70
src/lib/utils.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
type FlyAndScaleParams = {
|
||||
y?: number;
|
||||
x?: number;
|
||||
start?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const flyAndScale = (
|
||||
node: Element,
|
||||
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||
): TransitionConfig => {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
|
||||
const scaleConversion = (
|
||||
valueA: number,
|
||||
scaleA: [number, number],
|
||||
scaleB: [number, number]
|
||||
) => {
|
||||
const [minA, maxA] = scaleA;
|
||||
const [minB, maxB] = scaleB;
|
||||
|
||||
const percentage = (valueA - minA) / (maxA - minA);
|
||||
const valueB = percentage * (maxB - minB) + minB;
|
||||
|
||||
return valueB;
|
||||
};
|
||||
|
||||
const styleToString = (
|
||||
style: Record<string, number | string | undefined>
|
||||
): string => {
|
||||
return Object.keys(style).reduce((str, key) => {
|
||||
if (style[key] === undefined) return str;
|
||||
return str + key + ":" + style[key] + ";";
|
||||
}, "");
|
||||
};
|
||||
|
||||
return {
|
||||
duration: params.duration ?? 200,
|
||||
delay: 0,
|
||||
css: (t) => {
|
||||
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
|
||||
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
|
||||
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
|
||||
|
||||
return styleToString({
|
||||
transform:
|
||||
transform +
|
||||
"translate3d(" +
|
||||
x +
|
||||
"px, " +
|
||||
y +
|
||||
"px, 0) scale(" +
|
||||
scale +
|
||||
")",
|
||||
opacity: t,
|
||||
});
|
||||
},
|
||||
easing: cubicOut,
|
||||
};
|
||||
};
|
30
src/routes/+layout.svelte
Normal file
30
src/routes/+layout.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { Toaster } from '$lib/components/ui/sonner';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<div class="flex flex-col h-screen">
|
||||
<div id="header"></div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<div id="footer"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#header {
|
||||
height: var(--safe-area-inset-top);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#footer {
|
||||
height: var(--safe-area-inset-bottom);
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@ -0,0 +1 @@
|
||||
export const prerender = true;
|
316
src/routes/+page.svelte
Normal file
316
src/routes/+page.svelte
Normal file
@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import ScanCapture from '$lib/ScanCapture.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
||||
import { checkFileExists, createDirectory } from '$lib/cutils';
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showdialog = $state(false);
|
||||
|
||||
let focused = $state(false);
|
||||
let confirmation = $state(false);
|
||||
|
||||
const formValues: SvelteMap<string, string | undefined> = new SvelteMap();
|
||||
let sessioncount = $state(0);
|
||||
let daycount = $state(0);
|
||||
|
||||
let selected = $state('service');
|
||||
|
||||
function onscan(value: string) {
|
||||
// Set as empty if undefined
|
||||
if (formValues.get(selected) == undefined) {
|
||||
formValues.set(selected, '');
|
||||
}
|
||||
|
||||
if (value == 'Home') {
|
||||
showdialog = !showdialog;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == 'Delete') {
|
||||
reset();
|
||||
toast.success('Cleared');
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next input field
|
||||
if (value == 'Tab' || value == 'Enter') {
|
||||
if (selected == 'service') {
|
||||
selected = 'serial';
|
||||
} else if (selected == 'serial') {
|
||||
selected = 'description';
|
||||
} else if (selected == 'description') {
|
||||
selected = 'manufacturer';
|
||||
} else if (selected == 'manufacturer') {
|
||||
selected = 'model';
|
||||
} else if (selected == 'model') {
|
||||
selected = 'condition';
|
||||
} else if (confirmation == false) {
|
||||
confirmation = true;
|
||||
} else if (confirmation == true) {
|
||||
save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel confirmation
|
||||
if (value == 'Backspace' && confirmation == true) {
|
||||
confirmation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Go back to the previous input field
|
||||
if (value == 'Backspace' && formValues.get(selected)?.length == 0) {
|
||||
if (selected == 'condition') {
|
||||
selected = 'model';
|
||||
} else if (selected == 'model') {
|
||||
selected = 'manufacturer';
|
||||
} else if (selected == 'manufacturer') {
|
||||
selected = 'description';
|
||||
} else if (selected == 'description') {
|
||||
selected = 'serial';
|
||||
} else if (selected == 'serial') {
|
||||
selected = 'service';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Subtract one from the current input
|
||||
if (value == 'Backspace') {
|
||||
formValues.set(selected, formValues.get(selected)?.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Append current input with new text
|
||||
formValues.set(selected, formValues.get(selected) + value);
|
||||
}
|
||||
|
||||
async function onsubmit(event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) {
|
||||
event.preventDefault();
|
||||
Haptics.impact({ style: ImpactStyle.Medium });
|
||||
confirmation = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
Haptics.impact({ style: ImpactStyle.Medium });
|
||||
|
||||
// Create directory
|
||||
await createDirectory({
|
||||
path: 'inventory',
|
||||
directory: Directory.Documents
|
||||
});
|
||||
|
||||
const filename = `inventory/${new Date().toLocaleDateString().replaceAll('/', '-')}.csv`;
|
||||
|
||||
// Check if CSV file exists
|
||||
const exists = await checkFileExists({
|
||||
path: filename,
|
||||
directory: Directory.Documents
|
||||
});
|
||||
|
||||
// Create file if it does not exist
|
||||
if (!exists) {
|
||||
await Filesystem.writeFile({
|
||||
path: filename,
|
||||
data: '',
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
}
|
||||
|
||||
// Append data to file
|
||||
await Filesystem.appendFile({
|
||||
path: filename,
|
||||
data: `${formValues.get('service')},${formValues.get('serial')},${formValues.get('manufacturer')},${formValues.get('model')},${formValues.get('description')},${formValues.get('condition')},${new Date().toLocaleDateString()}\n`,
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
|
||||
sessioncount = sessioncount + 1;
|
||||
daycount = daycount + 1;
|
||||
toast.success('Added item to inventory');
|
||||
|
||||
// Reset values
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
confirmation = false;
|
||||
selected = 'service';
|
||||
formValues.set('serial', '');
|
||||
formValues.set('description', '');
|
||||
formValues.set('service', '');
|
||||
formValues.set('manufacturer', '');
|
||||
formValues.set('model', '');
|
||||
formValues.set('condition', '');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const filename = `inventory/${new Date().toLocaleDateString().replaceAll('/', '-')}.csv`;
|
||||
|
||||
// Check if CSV file exists
|
||||
const exists = await checkFileExists({
|
||||
path: filename,
|
||||
directory: Directory.Documents
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read CSV file
|
||||
const file = await Filesystem.readFile({
|
||||
path: filename,
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
|
||||
// Parse CSV file
|
||||
if (typeof file.data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = file.data.split("\n");
|
||||
daycount = lines.length - 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScanCapture {onscan} bind:focused />
|
||||
|
||||
<Dialog.Root bind:open={showdialog}>
|
||||
<Dialog.Content
|
||||
onEscapeKeydown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Stats</Dialog.Title>
|
||||
<div class="grid grid-cols-2 gap-2 py-4">
|
||||
<p>day count:</p>
|
||||
<p>{daycount}</p>
|
||||
<p>session count:</p>
|
||||
<p>{sessioncount}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'text-black cursor-pointer')}
|
||||
onclick={() => {
|
||||
sessioncount = 0;
|
||||
toast.success('Session count reset');
|
||||
}}>Reset session count</Button
|
||||
>
|
||||
</Dialog.Header>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<form class="flex flex-col gap-4 py-2 px-3 py-4" {onsubmit}>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="service">Asset Tag</Label>
|
||||
<Input
|
||||
id="service"
|
||||
name="service"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'service' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('service'), (v) => formValues.set('service', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="serial">Serial #</Label>
|
||||
<Input
|
||||
id="serial"
|
||||
name="serial"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'serial' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('serial'), (v) => formValues.set('serial', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="description">Machine Type</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'description' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('description'), (v) => formValues.set('description', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="manufacturer">Maker</Label>
|
||||
<Input
|
||||
id="manufacturer"
|
||||
name="manufacturer"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'manufacturer' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('manufacturer'), (v) => formValues.set('manufacturer', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="model">Model</Label>
|
||||
<Input
|
||||
id="model"
|
||||
name="model"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'model' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('model'), (v) => formValues.set('model', v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label class="basis-1/4" for="condition">Condition</Label>
|
||||
<Input
|
||||
id="condition"
|
||||
name="condition"
|
||||
type="text"
|
||||
disabled={confirmation}
|
||||
class={cn(!focused && selected == 'condition' && 'ring-offset-2 ring-2 ring-green-600')}
|
||||
bind:value={() => formValues.get('condition'), (v) => formValues.set('condition', v)}
|
||||
/>
|
||||
</div>
|
||||
{#if confirmation}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'text-black cursor-pointer')}
|
||||
onclick={() => {
|
||||
reset();
|
||||
toast.success('Canceled');
|
||||
}}>Cancel</Button
|
||||
>
|
||||
<Button type="button" class="cursor-pointer" onclick={() => save()}>Confirm</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'text-black cursor-pointer grow')}
|
||||
onclick={() => {
|
||||
showdialog = !showdialog;
|
||||
}}>Stats</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
class={cn(buttonVariants({ variant: 'outline' }), 'text-black cursor-pointer grow')}
|
||||
onclick={() => {
|
||||
reset();
|
||||
toast.success('Cleared');
|
||||
}}>Reset</Button
|
||||
>
|
||||
</div>
|
||||
<Button type="submit" class="cursor-pointer">Submit</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
Reference in New Issue
Block a user