This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { RecordModel } from 'pocketbase';
import type PocketBase from 'pocketbase';
declare global {
namespace App {
interface Locals {
pb: PocketBase;
user: RecordModel | null;
}
}
}
export {};
+18
View File
@@ -0,0 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type PocketBase from "pocketbase";
declare global {
namespace App {
// interface Error {}
interface Locals {
pb: PocketBase;
user: Record<string, unknown> | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View 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="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { redirect } from "@sveltejs/kit";
import PocketBase from "pocketbase";
import type { Handle } from "@sveltejs/kit";
const POCKETBASE_URL = "https://pocketbase.ccllc.pro";
const LOGIN_PATH = "/login";
const LOGOUT_PATH = "/logout";
function isPublicRoute(pathname: string): boolean {
return (
pathname === LOGIN_PATH ||
pathname.startsWith(LOGIN_PATH + "/") ||
pathname === LOGOUT_PATH
);
}
export const handle: Handle = async ({ event, resolve }) => {
event.locals.pb = new PocketBase(POCKETBASE_URL);
event.locals.pb.authStore.loadFromCookie(event.request.headers.get("cookie") || "");
try {
if (event.locals.pb.authStore.isValid) {
await event.locals.pb.collection("users").authRefresh();
}
} catch {
event.locals.pb.authStore.clear();
}
event.locals.user = event.locals.pb.authStore.record ?? null;
if (!event.locals.user && !isPublicRoute(event.url.pathname)) {
const redirectTo = event.url.pathname + event.url.search;
const loginUrl =
redirectTo && redirectTo !== "/"
? `${LOGIN_PATH}?redirectTo=${encodeURIComponent(redirectTo)}`
: LOGIN_PATH;
throw redirect(303, loginUrl);
}
const response = await resolve(event);
response.headers.append(
"set-cookie",
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: "Lax" })
);
return response;
};
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "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 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "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 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-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">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View 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,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
+6
View File
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<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
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<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
data-slot="dialog-overlay"
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/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<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
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
+34
View File
@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
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";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
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,
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,43 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,33 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ms-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,54 @@
import Root from "./dropdown-menu.svelte";
import Sub from "./dropdown-menu-sub.svelte";
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
import Portal from "./dropdown-menu-portal.svelte";
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
+20
View File
@@ -0,0 +1,20 @@
<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
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>
+37
View File
@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
+28
View File
@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>
+34
View File
@@ -0,0 +1,34 @@
/** UOM options for Stackq_Items (single select). */
export const UOM_OPTIONS = ["EA", "BX", "CA", "PK", "PLT", "LF"] as const;
/** Catagory options (single select in schema). */
export const CATAGORY_OPTIONS = [
"Framing",
"Insulation",
"Drywall",
"T-Bar",
"Painting",
"Masking",
"Misc",
] as const;
/** Sub_Catagory options (single select in schema). */
export const SUB_CATAGORY_OPTIONS = [
"ACT",
"Channel",
"Fasteners",
"Finishing Materials",
"Adhesives",
"Mud",
"Corner Bead",
"Sanding",
"Tape",
"Metal Studs",
"Metal Tracks",
"FRP",
"Gyp",
"Insulation",
"Paint",
"Weatherproofing",
"Misc",
] as const;
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias here
+5
View File
@@ -0,0 +1,5 @@
import PocketBase from "pocketbase";
const POCKETBASE_URL = "https://pocketbase.ccllc.pro";
export const pb = new PocketBase(POCKETBASE_URL);
+20
View File
@@ -0,0 +1,20 @@
/** PocketBase Stackq_Items collection record (and expanded relation). */
export interface StackqItem {
id: string;
Item: string;
SKU: string;
Description: string;
UOM: string;
Cost: number;
Vendor: string;
Catagory: string | string[];
Sub_Catagory: string | string[];
Has_Parent: boolean;
Parent: string; // relation id
Stock: boolean;
created: string;
updated: string;
expand?: {
Parent?: { id: string; Item?: string; SKU?: string };
};
}
+13
View File
@@ -0,0 +1,13 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import "./layout.css";
</script>
<slot />
View File
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
</script>
<main class="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 class="text-2xl font-semibold">Stackq</h1>
<p class="text-muted-foreground">Svelte 5 + shadcn-svelte + PocketBase</p>
<div class="flex gap-2">
<a href="/items">
<Button>Items table</Button>
</a>
<a href="/logout">
<Button variant="outline">Log out</Button>
</a>
</div>
</main>
+124
View File
@@ -0,0 +1,124 @@
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
function parseNum(val: unknown): number | undefined {
if (val === "" || val === null || val === undefined) return undefined;
const n = Number(val);
return Number.isFinite(n) ? n : undefined;
}
function parseBool(val: unknown): boolean {
return val === "on" || val === "true" || val === true;
}
export const load: PageServerLoad = async ({ locals }) => {
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({
sort: "-created",
expand: "Parent",
})
.catch((err) => {
console.error(COLLECTION, err);
return [] as StackqItem[];
});
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { items: list, itemsForParent };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
if (!Item) return fail(400, { error: "Item name is required", form: "create" });
try {
await locals.pb.collection(COLLECTION).create({
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
});
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Create failed";
return fail(500, { error: message, form: "create" });
}
return { success: true };
},
update: async ({ request, locals }) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "update" });
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
await locals.pb.collection(COLLECTION).update(id, {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
});
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ request, locals }) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
return { success: true };
},
};
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
import { browser } from "$app/environment";
import * as Table from "$lib/components/ui/table";
import { Badge } from "$lib/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import type { StackqItem } from "$lib/types/items";
let { data }: { data: { items: StackqItem[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
const items = $derived(data?.items ?? []);
</script>
<svelte:head>
<title>Items Stackq</title>
</svelte:head>
{#if browser}
{#await import("./ItemsCrud.svelte")}
<main class="container mx-auto flex flex-col gap-6 py-8">
<p class="text-muted-foreground">Loading…</p>
</main>
{:then { default: ItemsCrud }}
<ItemsCrud data={data} />
{/await}
{:else}
<main class="container mx-auto flex flex-col gap-6 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Stackq Items</h1>
<span class="text-muted-foreground text-sm">{items.length} items</span>
</div>
<Card>
<CardHeader>
<CardTitle>Stackq_Items</CardTitle>
<p class="text-muted-foreground text-sm">PocketBase collection. Enable JS for create/edit/delete.</p>
</CardHeader>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Item</Table.Head>
<Table.Head>SKU</Table.Head>
<Table.Head class="max-w-[200px]">Description</Table.Head>
<Table.Head>UOM</Table.Head>
<Table.Head class="text-right">Cost</Table.Head>
<Table.Head>Vendor</Table.Head>
<Table.Head>Catagory</Table.Head>
<Table.Head>Sub_Catagory</Table.Head>
<Table.Head>Has Parent</Table.Head>
<Table.Head>Parent</Table.Head>
<Table.Head>Stock</Table.Head>
<Table.Head class="text-muted-foreground">created</Table.Head>
<Table.Head class="text-muted-foreground">updated</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if items.length === 0}
<Table.Row>
<Table.Cell colspan="13" class="h-24 text-center text-muted-foreground">
No items.
</Table.Cell>
</Table.Row>
{:else}
{#each items as item (item.id)}
<Table.Row>
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
{item.Description ?? "—"}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
</Table.Cell>
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
<Table.Cell>
{#each asList(item.Catagory) as cat}
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
{/each}
{#if asList(item.Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#each asList(item.Sub_Catagory) as sub}
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
{/each}
{#if asList(item.Sub_Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Has_Parent}
<Badge>Yes</Badge>
{:else}
<span class="text-muted-foreground">No</span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground">
{#if item.expand?.Parent}
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
{:else if item.Parent}
<span class="font-mono text-xs">{item.Parent}</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Stock}
<Badge variant="default">Stock</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.created)}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.updated)}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
</main>
{/if}
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { enhance } from "$app/forms";
import { UOM_OPTIONS, CATAGORY_OPTIONS, SUB_CATAGORY_OPTIONS } from "$lib/constants/items";
import type { StackqItem } from "$lib/types/items";
let {
item = null,
itemsForParent = [],
action,
submitLabel,
error,
onSuccess,
}: {
item?: StackqItem | null;
itemsForParent?: { id: string; Item: string; SKU: string }[];
action: string;
submitLabel: string;
error?: string | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const parentOptions = $derived(
item ? itemsForParent.filter((p) => p.id !== item!.id) : itemsForParent
);
const defaultItem = $derived(item?.Item ?? "");
const defaultSku = $derived(item?.SKU ?? "");
const defaultDesc = $derived(item?.Description ?? "");
const defaultUom = $derived(item?.UOM ?? "");
const defaultCost = $derived(item?.Cost != null ? String(item.Cost) : "");
const defaultVendor = $derived(item?.Vendor ?? "");
const defaultCat = $derived(Array.isArray(item?.Catagory) ? item!.Catagory[0] : item?.Catagory ?? "");
const defaultSub = $derived(Array.isArray(item?.Sub_Catagory) ? item!.Sub_Catagory[0] : item?.Sub_Catagory ?? "");
const defaultParent = $derived(item?.Parent ?? "");
const defaultHasParent = $derived(!!item?.Has_Parent);
const defaultStock = $derived(!!item?.Stock);
</script>
<form
method="POST"
action={action}
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === "success" && result.data?.success) {
onSuccess?.();
}
}}
class="flex flex-col gap-4"
>
{#if isEdit}
<input type="hidden" name="id" value={item!.id} />
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Item">Item</Label>
<Input id="Item" name="Item" value={defaultItem} required />
</div>
<div class="space-y-2">
<Label for="SKU">SKU</Label>
<Input id="SKU" name="SKU" value={defaultSku} />
</div>
</div>
<div class="space-y-2">
<Label for="Description">Description</Label>
<Input id="Description" name="Description" value={defaultDesc} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="UOM">UOM</Label>
<select
id="UOM"
name="UOM"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each UOM_OPTIONS as o}
<option value={o} selected={defaultUom === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Cost">Cost</Label>
<Input id="Cost" name="Cost" type="number" step="any" value={defaultCost} />
</div>
</div>
<div class="space-y-2">
<Label for="Vendor">Vendor</Label>
<Input id="Vendor" name="Vendor" value={defaultVendor} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Catagory">Catagory</Label>
<select
id="Catagory"
name="Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each CATAGORY_OPTIONS as o}
<option value={o} selected={defaultCat === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Sub_Catagory">Sub_Catagory</Label>
<select
id="Sub_Catagory"
name="Sub_Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each SUB_CATAGORY_OPTIONS as o}
<option value={o} selected={defaultSub === o}>{o}</option>
{/each}
</select>
</div>
</div>
<div class="space-y-2">
<Label for="Parent">Parent</Label>
<select
id="Parent"
name="Parent"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each parentOptions as p}
<option value={p.id} selected={defaultParent === p.id}>
{p.Item || p.SKU || p.id}
</option>
{/each}
</select>
</div>
<div class="flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Has_Parent"
value="on"
checked={defaultHasParent}
class="border-input size-4 rounded"
/>
Has Parent
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Stock"
value="on"
checked={defaultStock}
class="border-input size-4 rounded"
/>
Stock
</label>
</div>
{#if error}
<p class="text-destructive text-sm">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="submit">{submitLabel}</Button>
</div>
</form>
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { enhance } from "$app/forms";
import ItemForm from "./ItemForm.svelte";
import { Ellipsis, Pencil, Plus, Trash2 } from "@lucide/svelte";
import type { StackqItem } from "$lib/types/items";
let {
data,
}: {
data: {
items: StackqItem[];
itemsForParent: { id: string; Item: string; SKU: string }[];
};
} = $props();
let dialogOpen = $state(false);
let editingItem = $state<StackqItem | null>(null);
let formWhich = $state<"create" | "update">("create");
const items = $derived(data?.items ?? []);
const itemsForParent = $derived(data?.itemsForParent ?? []);
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
function openCreate() {
editingItem = null;
formWhich = "create";
dialogOpen = true;
}
function openEdit(item: StackqItem) {
editingItem = item;
formWhich = "update";
dialogOpen = true;
}
function onFormSuccess() {
dialogOpen = false;
editingItem = null;
}
const formError = $derived(
data?.form === "create" || data?.form === "update" ? (data?.error as string | undefined) ?? null : null
);
$effect(() => {
if (formError && (data?.form === "create" || data?.form === "update")) {
dialogOpen = true;
}
});
</script>
<svelte:head>
<title>Items Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Stackq Items</h1>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">{items.length} items</span>
<Button onclick={openCreate}>
<Plus class="mr-2 size-4" />
New item
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Stackq_Items</CardTitle>
<p class="text-muted-foreground text-sm">
PocketBase collection: full CRUD.
</p>
</CardHeader>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Item</Table.Head>
<Table.Head>SKU</Table.Head>
<Table.Head class="max-w-[200px]">Description</Table.Head>
<Table.Head>UOM</Table.Head>
<Table.Head class="text-right">Cost</Table.Head>
<Table.Head>Vendor</Table.Head>
<Table.Head>Catagory</Table.Head>
<Table.Head>Sub_Catagory</Table.Head>
<Table.Head>Has Parent</Table.Head>
<Table.Head>Parent</Table.Head>
<Table.Head>Stock</Table.Head>
<Table.Head class="text-muted-foreground">created</Table.Head>
<Table.Head class="text-muted-foreground">updated</Table.Head>
<Table.Head class="w-[70px]">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if items.length === 0}
<Table.Row>
<Table.Cell colspan="14" class="h-24 text-center text-muted-foreground">
No items. Create one or check PocketBase collection name and auth.
</Table.Cell>
</Table.Row>
{:else}
{#each items as item (item.id)}
<Table.Row>
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
{item.Description ?? "—"}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
</Table.Cell>
<Table.Cell class="text-right tabular-nums">
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
</Table.Cell>
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
<Table.Cell>
{#each asList(item.Catagory) as cat}
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
{/each}
{#if asList(item.Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#each asList(item.Sub_Catagory) as sub}
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
{/each}
{#if asList(item.Sub_Catagory).length === 0}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Has_Parent}
<Badge>Yes</Badge>
{:else}
<span class="text-muted-foreground">No</span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground">
{#if item.expand?.Parent}
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
{:else if item.Parent}
<span class="font-mono text-xs">{item.Parent}</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if item.Stock}
<Badge variant="default">Stock</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{formatDate(item.created)}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{formatDate(item.updated)}
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-0 bg-transparent hover:bg-muted inline-flex size-8 items-center justify-center rounded-md"
>
<Ellipsis class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(item)}>
<Pencil class="mr-2 size-4" />
Edit
</DropdownMenu.Item>
<form
id="delete-form-{item.id}"
method="POST"
action="?/delete"
use:enhance
class="hidden"
>
<input type="hidden" name="id" value={item.id} />
</form>
<DropdownMenu.Item
class="text-destructive focus:text-destructive cursor-pointer"
onclick={() => {
if (confirm("Delete this item? This cannot be undone.")) {
document.getElementById("delete-form-" + item.id)?.requestSubmit();
}
}}
>
<Trash2 class="mr-2 size-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{editingItem ? "Edit item" : "New item"}</Dialog.Title>
<Dialog.Description>
{editingItem ? "Update the item below." : "Add a new Stackq item."}
</Dialog.Description>
</Dialog.Header>
<ItemForm
item={editingItem}
itemsForParent={itemsForParent}
action={editingItem ? "?/update" : "?/create"}
submitLabel={editingItem ? "Update" : "Create"}
error={formError ?? null}
onSuccess={onFormSuccess}
/>
</Dialog.Content>
</Dialog.Root>
</main>
+108
View File
@@ -0,0 +1,108 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
function safeRedirectTo(searchParams: URLSearchParams): string {
const to = searchParams.get("redirectTo");
if (!to || typeof to !== "string") return "/";
if (!to.startsWith("/") || to.startsWith("//")) return "/";
return to;
}
export const load: PageServerLoad = ({ url, locals }) => {
if (locals.user) {
throw redirect(303, safeRedirectTo(url.searchParams));
}
};
export const actions: Actions = {
login: async ({ request, locals, url }) => {
const data = await request.formData();
const email = (data.get("email") as string)?.trim() ?? "";
const password = (data.get("password") as string) ?? "";
if (!email || !password) {
return fail(400, { error: "Email and password are required.", email });
}
try {
await locals.pb.collection("users").authWithPassword(email, password);
} catch (err: unknown) {
const message =
err && typeof err === "object" && "message" in err
? String((err as { message: string }).message)
: "Invalid email or password.";
return fail(401, { error: message, email });
}
const redirectTo = safeRedirectTo(url.searchParams);
throw redirect(303, redirectTo);
},
};
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import { browser } from "$app/environment";
import type { ActionData } from "./$types";
let { data }: { data: ActionData } = $props();
</script>
<main class="flex min-h-screen flex-col items-center justify-center p-4">
{#if browser}
{#await import("./LoginForm.svelte")}
<p class="text-muted-foreground">Loading…</p>
{:then { default: LoginForm }}
<LoginForm data={data} />
{/await}
{:else}
<div class="w-full max-w-sm space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div class="space-y-1">
<h1 class="text-lg font-semibold">Login</h1>
<p class="text-muted-foreground text-sm">Enter your email and password to sign in.</p>
</div>
<p class="text-muted-foreground text-sm">Loading form…</p>
</div>
{/if}
</main>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
let { data }: { data: ActionData } = $props();
</script>
<Card.Root class="w-full max-w-sm">
<Card.Header>
<Card.Title>Login</Card.Title>
<Card.Description>Enter your email and password to sign in.</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/login"
use:enhance={() => async ({ update }) => {
await update();
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
value={data?.email ?? ""}
required
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<a
href="/forgot-password"
class="text-muted-foreground text-sm underline hover:text-foreground"
>
Forgot your password?
</a>
</div>
<Input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
/>
</div>
{#if data?.error}
<p class="text-destructive text-sm">{data.error}</p>
{/if}
<Button type="submit" class="w-full">Login</Button>
</form>
</Card.Content>
</Card.Root>
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = ({ locals }) => {
locals.pb.authStore.clear();
throw redirect(303, "/login");
};