feat(webapp): redesign dashboard with shadcn-style UI shell

This commit is contained in:
2026-03-16 11:00:00 +00:00
parent c6919d8174
commit eb0fbf24f0
95 changed files with 5940 additions and 539 deletions

199
WebApp/src/app.css Normal file
View File

@@ -0,0 +1,199 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@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-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--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 outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* Preserve existing simulator utility styles alongside shadcn tokens. */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgb(0 0 0 / 20%);
}
::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 10%);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(255 255 255 / 20%);
}
.glass-panel {
background: rgb(15 15 20 / 70%);
backdrop-filter: blur(16px);
border: 1px solid rgb(255 255 255 / 5%);
}
.glass-card {
background: rgb(25 25 30 / 60%);
border: 1px solid rgb(255 255 255 / 8%);
}
.btn-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
color: white;
transition: all 0.2s ease;
}
.btn-premium:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
}
.status-dot {
height: 8px;
width: 8px;
border-radius: 50%;
display: inline-block;
}
.status-online {
background-color: #10b981;
box-shadow: 0 0 8px rgb(16 185 129 / 40%);
}
.status-offline {
background-color: #ef4444;
box-shadow: 0 0 8px rgb(239 68 68 / 40%);
}
@keyframes slideIn {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.toast-enter {
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

View File

@@ -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="alert-action"
class={cn("absolute top-2 right-2", className)}
{...restProps}
>
{@render children?.()}
</div>

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="alert-description"
class={cn(
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className
)}
{...restProps}
>
{@render children?.()}
</div>

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="alert-title"
class={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
role="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,17 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
import Action from "./alert-action.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
Action,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
Action as AlertAction,
};

View File

@@ -0,0 +1,26 @@
<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="avatar-badge"
class={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn(
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...restProps}
/>

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="avatar-group-count"
class={cn(
"bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
className
)}
{...restProps}
>
{@render children?.()}
</div>

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="avatar-group"
class={cn(
"cn-avatar-group *:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("rounded-full aspect-square size-full object-cover", className)}
{...restProps}
/>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
size = "default",
class: className,
...restProps
}: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg";
} = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
data-size={size}
class={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,22 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
import Badge from "./avatar-badge.svelte";
import Group from "./avatar-group.svelte";
import GroupCount from "./avatar-group-count.svelte";
export {
Root,
Image,
Fallback,
Badge,
Group,
GroupCount,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
Badge as AvatarBadge,
Group as AvatarGroup,
GroupCount as AvatarGroupCount,
};

View File

@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
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>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -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 dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
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}

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,
};

View File

@@ -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-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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>

View File

@@ -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("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

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,
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import XIcon from '@lucide/svelte/icons/x';
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-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm" {...props}>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -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 *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>

View File

@@ -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("gap-2 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -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-base leading-none font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />

View File

@@ -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} />

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,
};

View File

@@ -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}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
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 focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,31 @@
<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,
align = "start",
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}
{align}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
className
)}
{...restProps}
/>
</DropdownMenuPortal>

View File

@@ -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}
/>

View File

@@ -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} />

View File

@@ -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(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -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("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7 data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />

View File

@@ -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}
/>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from '@lucide/svelte/icons/check';
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 focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View File

@@ -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}
/>

View File

@@ -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 group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -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.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
{...restProps}
/>

View File

@@ -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(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,48 @@
<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(
"dark:bg-input/30 border-input 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"dark:bg-input/30 border-input 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

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(
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,10 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
{orientation}
class={cn(
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="rounded-full bg-border relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { Scrollbar } from "./index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = "vertical",
scrollbarXClasses = "",
scrollbarYClasses = "",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: "vertical" | "horizontal" | "both" | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn("relative", className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="cn-scroll-area-viewport focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

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,
};

View File

@@ -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-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 overflow-x-hidden overflow-y-auto",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@@ -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>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn("scroll-my-1 p-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
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(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="cn-select-item-indicator-icon" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -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-1.5 py-1 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
{...restProps}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
{...restProps}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>

View File

@@ -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 -mx-1 my-1 h-px pointer-events-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
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 dark:bg-input/30 dark:hover:bg-input/50 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:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</SelectPrimitive.Trigger>

View File

@@ -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} />

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,23 @@
<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]:w-px",
// this is different in shadcn/ui but self-stretch breaks things for us
"data-[orientation=vertical]:h-full",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,10 @@
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem,
};

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { getToggleGroupCtx } from "./toggle-group.svelte";
import { cn } from "$lib/utils.js";
import { type ToggleVariants, toggleVariants } from "$lib/components/ui/toggle/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
data-slot="toggle-group-item"
data-variant={ctx.variant || variant}
data-size={ctx.size || size}
data-spacing={ctx.spacing}
class={cn(
"group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size,
}),
className
)}
{value}
{...restProps}
/>

View File

@@ -0,0 +1,75 @@
<script lang="ts" module>
import { getContext, setContext } from "svelte";
import type { VariantProps } from "tailwind-variants";
import { toggleVariants } from "$lib/components/ui/toggle/index.js";
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
orientation?: "horizontal" | "vertical";
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext<Required<ToggleGroupContext>>("toggleGroup");
}
</script>
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
spacing = 0,
orientation = "horizontal",
variant = "default",
...restProps
}: ToggleGroupPrimitive.RootProps &
ToggleVariants & {
spacing?: number;
orientation?: "horizontal" | "vertical";
} = $props();
setToggleGroupCtx({
get variant() {
return variant;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
get orientation() {
return orientation;
},
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value as never}
bind:ref
{orientation}
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={`--gap: ${spacing}`}
class={cn(
"rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./toggle.svelte";
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants,
} from "./toggle.svelte";
export {
Root,
//
Root as Toggle,
};

View File

@@ -0,0 +1,51 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[state=on]:bg-muted gap-1 rounded-lg text-sm font-medium transition-all [&_svg:not([class*='size-'])]:size-4 group/toggle hover:bg-muted inline-flex items-center justify-center whitespace-nowrap outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
outline: "border-input hover:bg-muted border bg-transparent",
},
size: {
default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
data-slot="toggle"
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>

View File

@@ -2,131 +2,204 @@
// @ts-nocheck
import { appController } from '$lib/app/controller';
import { appState, unreadNotificationsCount } from '$lib/app/store';
import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '$lib/components/ui/dialog/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
let { children, pageKey } = $props<{ children: () => unknown; pageKey: string }>();
let recordingDialogOpen = $state(false);
$effect(() => {
appController.setPage(pageKey);
});
$effect(() => {
recordingDialogOpen = $appState.recordingModal.open;
});
$effect(() => {
if (!recordingDialogOpen && $appState.recordingModal.open) {
appController.closeRecordingModal();
}
});
const isHomePage = (page: string) => page === 'camera' || page === 'client';
const isAuthenticated = () => Boolean($appState.session && $appState.device);
const shouldReserveSidebar = () => $appState.loading && pageKey !== 'auth' && pageKey !== 'onboarding';
const showSidebar = () => isAuthenticated() || shouldReserveSidebar();
const showSidebarSkeleton = () => shouldReserveSidebar() && !isAuthenticated();
const userName = () => $appState.session?.user?.name || 'Signed Out';
const userEmail = () => $appState.session?.user?.email || 'Signed Out';
const userInitial = () => ($appState.session?.user?.name?.[0] || '?').toUpperCase();
const navVariant = (active: boolean) => (active ? 'secondary' : 'ghost');
const toastTitle = (type: string) => (type ? `${type[0].toUpperCase()}${type.slice(1)}` : 'Notice');
</script>
<div data-sim-page={pageKey} class="flex h-full w-full">
<div id="toast-container" class="toast toast-top toast-end z-50">
<div id="toast-container" class="fixed top-4 right-4 z-50 flex w-[22rem] flex-col gap-3">
{#each $appState.toasts as toast (toast.id)}
<div class={`alert ${toast.type === 'error' ? 'alert-error' : toast.type === 'success' ? 'alert-success' : 'alert-info'} text-white shadow-lg text-xs py-2 px-3 flex flex-row gap-2`}>
<span>{toast.message}</span>
<button class="btn btn-xs btn-ghost" onclick={() => appController.removeToast(toast.id)}>x</button>
</div>
<Alert
variant={toast.type === 'error' ? 'destructive' : 'default'}
class="glass-card border-white/10 bg-background/95 shadow-lg"
>
<AlertTitle class="text-xs">{toastTitle(toast.type)}</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">{toast.message}</AlertDescription>
<AlertAction>
<Button
variant="ghost"
size="icon-xs"
class="text-gray-400 hover:text-white"
onclick={() => appController.removeToast(toast.id)}
>
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="size-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Dismiss notification</span>
</Button>
</AlertAction>
</Alert>
{/each}
</div>
<aside
id="bottomNav"
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between h-full {isAuthenticated() ? 'flex' : 'hidden'}"
class="w-20 lg:w-72 glass-panel border-r border-white/5 flex-col justify-between h-full {showSidebar() ? 'flex' : 'hidden'}"
>
<div class="p-4 lg:p-6 border-b border-white/5">
<div id="authStatusBadge" class="flex items-center justify-center lg:justify-start gap-3 text-sm text-gray-300">
<div
class="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10 shrink-0"
>
{userInitial()}
{#if showSidebarSkeleton()}
<div class="p-4 lg:p-6">
<div class="flex items-center justify-center gap-3 lg:justify-start">
<div class="size-10 rounded-full bg-white/8 animate-pulse"></div>
<div class="hidden min-w-0 flex-1 flex-col gap-2 lg:flex">
<div class="h-3 w-28 rounded-full bg-white/8 animate-pulse"></div>
<div class="h-2.5 w-36 rounded-full bg-white/6 animate-pulse"></div>
</div>
</div>
<div class="hidden lg:block min-w-0">
<p class="font-semibold text-white truncate" title={userEmail()}>{userName()}</p>
<p class="text-xs text-gray-500 truncate" title={userEmail()}>{userEmail()}</p>
<div class="mt-3 flex items-center justify-center lg:justify-start">
<div class="h-6 w-24 rounded-full bg-white/8 animate-pulse"></div>
</div>
</div>
<div id="connectionStatus" class="mt-3 flex items-center justify-center lg:justify-start gap-2">
<span class="status-dot {$appState.socketConnected ? 'status-online' : 'status-offline'} transition-colors duration-300"></span>
<span class="hidden lg:inline text-[11px] text-gray-400 font-medium tracking-wide uppercase">
{$appState.socketConnected ? 'ONLINE' : 'OFFLINE'}
</span>
<Separator class="bg-white/5" />
<nav class="flex flex-1 flex-col gap-2 py-6 px-3">
{#each Array(3) as _, index (index)}
<div class="h-12 w-full rounded-xl bg-white/6 animate-pulse"></div>
{/each}
</nav>
{:else}
<div class="p-4 lg:p-6">
<div id="authStatusBadge" class="flex items-center justify-center lg:justify-start gap-3 text-sm text-gray-300">
<Avatar size="lg" class="bg-gray-800/80 text-xs font-bold text-white">
<AvatarFallback>{userInitial()}</AvatarFallback>
</Avatar>
<div class="hidden lg:block min-w-0">
<p class="font-semibold text-white truncate" title={userEmail()}>{userName()}</p>
<p class="text-xs text-gray-500 truncate" title={userEmail()}>{userEmail()}</p>
</div>
</div>
<div id="connectionStatus" class="mt-3 flex items-center justify-center lg:justify-start gap-2">
<Badge
variant={$appState.socketConnected ? 'secondary' : 'destructive'}
class="gap-2 rounded-full px-2.5 py-1 text-[11px] uppercase tracking-wide"
>
<span class="status-dot {$appState.socketConnected ? 'status-online' : 'status-offline'} transition-colors duration-300"></span>
{$appState.socketConnected ? 'ONLINE' : 'OFFLINE'}
</Badge>
</div>
</div>
</div>
<Separator class="bg-white/5" />
<nav class="flex-1 py-6 px-3 space-y-2">
<button
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
data-active={isHomePage($appState.page)}
onclick={() => appController.navigate('home')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<nav class="flex flex-1 flex-col gap-2 py-6 px-3">
<Button
variant={navVariant(isHomePage($appState.page))}
class="nav-btn h-12 w-full justify-center gap-3 rounded-xl border border-transparent text-gray-400 hover:text-white lg:justify-start"
onclick={() => appController.navigate('home')}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
data-icon="inline-start"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
</Button>
<button
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
data-active={$appState.page === 'activity'}
onclick={() => appController.navigate('activity')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<Button
variant={navVariant($appState.page === 'activity')}
class="nav-btn relative h-12 w-full justify-center gap-3 rounded-xl border border-transparent text-gray-400 hover:text-white lg:justify-start"
onclick={() => appController.navigate('activity')}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
<span
id="notificationDot"
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full {$unreadNotificationsCount > 0 ? '' : 'hidden'}"
></span>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
data-icon="inline-start"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
{#if $unreadNotificationsCount > 0}
<Badge
id="notificationDot"
variant="destructive"
class="absolute top-2.5 right-2 size-2 rounded-full p-0 lg:hidden"
/>
<Badge
variant="destructive"
class="ml-auto hidden min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] lg:inline-flex"
>
{$unreadNotificationsCount}
</Badge>
{/if}
</Button>
<button
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
data-active={$appState.page === 'settings'}
onclick={() => appController.navigate('settings')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<Button
variant={navVariant($appState.page === 'settings')}
class="nav-btn h-12 w-full justify-center gap-3 rounded-xl border border-transparent text-gray-400 hover:text-white lg:justify-start"
onclick={() => appController.navigate('settings')}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Settings</span>
</button>
</nav>
<svg
xmlns="http://www.w3.org/2000/svg"
data-icon="inline-start"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span class="font-medium hidden lg:block text-sm">Settings</span>
</Button>
</nav>
{/if}
</aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
@@ -136,42 +209,41 @@
</main>
</div>
<div
id="recordingModal"
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] {$appState.recordingModal.open ? 'flex' : 'hidden'} items-center justify-center p-4 lg:p-10"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(event) => {
if (event.target === event.currentTarget) appController.closeRecordingModal();
}}
onkeydown={(event) => {
if (event.key === 'Escape') appController.closeRecordingModal();
}}
>
<div
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]"
<Dialog bind:open={recordingDialogOpen}>
<DialogContent
id="recordingModal"
showCloseButton={false}
class="glass-card max-w-5xl border-white/10 bg-[#101014]/95 p-0 shadow-2xl sm:max-w-5xl"
>
<div class="flex items-center justify-between shrink-0">
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">
{$appState.recordingModal.title}
</h3>
<button
id="recordingModalCloseBtn"
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10"
aria-label="Close recording modal"
title="Close recording modal"
onclick={() => appController.closeRecordingModal()}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<DialogHeader class="gap-3 px-6 pt-6">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<DialogTitle id="recordingModalTitle" class="truncate text-lg font-semibold text-white tracking-wide">
{$appState.recordingModal.title}
</DialogTitle>
<DialogDescription class="sr-only">Playback of the selected recording.</DialogDescription>
</div>
<Button
id="recordingModalCloseBtn"
variant="ghost"
size="icon-sm"
class="text-gray-400 hover:text-white"
aria-label="Close recording modal"
title="Close recording modal"
onclick={() => appController.closeRecordingModal()}
>
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
</DialogHeader>
<div class="px-6 pb-6">
<div class="min-h-0 overflow-hidden rounded-2xl border border-white/5 bg-black shadow-inner">
<video id="recordingModalVideo" class="h-full w-full object-contain" src={$appState.recordingModal.url} controls playsinline>
<track kind="captions" />
</video>
</div>
</div>
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
<video id="recordingModalVideo" class="w-full h-full object-contain" src={$appState.recordingModal.url} controls playsinline>
<track kind="captions" />
</video>
</div>
</div>
</div>
</DialogContent>
</Dialog>

13
WebApp/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } 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 };

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import './layout.css';
import '../app.css';
import { onMount } from 'svelte';
import favicon from '$lib/assets/favicon.svg';
import { appController } from '$lib/app/controller';
@@ -7,26 +7,16 @@
let { children } = $props();
onMount(() => {
const bodyClasses = ['h-screen', 'bg-[#0a0a0c]', 'text-gray-200', 'overflow-hidden', 'flex'];
document.body.classList.add(...bodyClasses);
document.documentElement.dataset.theme = 'black';
void appController.init();
return () => {
void appController.destroy();
document.body.classList.remove(...bodyClasses);
};
});
</script>
<svelte:head>
<title>SecureCam Web Dashboard</title>
<title>PhoneCam Web Dashboard</title>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
{@render children()}

View File

@@ -3,75 +3,93 @@
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
</script>
<AppChrome pageKey="auth">
<section id="screen-auth" class="flex flex-col items-center justify-center min-h-[70vh] animate-fade-in max-w-sm mx-auto">
<div class="text-center space-y-3 mb-8">
<div
class="w-20 h-20 bg-gradient-to-tr from-blue-600 to-indigo-600 rounded-3xl mx-auto flex items-center justify-center shadow-lg shadow-blue-900/20"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h2 class="text-3xl font-bold text-white tracking-tight">SecureCam Web</h2>
<p class="text-gray-400 text-sm">Sign in to manage visual security from your browser.</p>
</div>
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-4 shadow-2xl">
<div class="form-control">
<label class="label hidden" for="authEmail"><span class="label-text text-gray-400">Email</span></label>
<input
id="authEmail"
type="email"
placeholder="Email address"
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl"
value={$appState.authForm.email}
oninput={(event) => appController.setAuthField('email', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="form-control">
<input
id="authPassword"
type="password"
placeholder="Password"
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl"
value={$appState.authForm.password}
oninput={(event) => appController.setAuthField('password', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{#if $appState.isRegistering}
<div id="authNameField" class="form-control">
<input
id="authName"
type="text"
placeholder="Your Name"
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl"
value={$appState.authForm.name}
oninput={(event) => appController.setAuthField('name', (event.currentTarget as HTMLInputElement).value)}
<Card class="glass-card w-full rounded-3xl border-white/10 bg-[#16161d]/80 shadow-2xl">
<CardHeader class="items-center gap-4 px-6 pt-6 text-center">
<div
class="flex size-20 items-center justify-center rounded-3xl bg-gradient-to-tr from-blue-600 to-indigo-600 shadow-lg shadow-blue-900/20"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div class="flex flex-col gap-2">
<CardTitle class="text-3xl font-bold tracking-tight text-white">PhoneCam Web</CardTitle>
<CardDescription class="text-sm text-gray-400">Sign in to manage visual security from your browser.</CardDescription>
</div>
</CardHeader>
<CardContent class="flex flex-col gap-4 px-6">
<div class="flex flex-col gap-2">
<Label class="sr-only" for="authEmail">Email</Label>
<Input
id="authEmail"
type="email"
placeholder="Email address"
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500"
value={$appState.authForm.email}
oninput={(event) => appController.setAuthField('email', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{/if}
<div class="pt-4 space-y-4">
<button id="signInBtn" class="btn btn-premium w-full h-12 rounded-xl shadow-lg shadow-blue-900/20 text-base" onclick={() => appController.submitAuth()}>
<div class="flex flex-col gap-2">
<Label class="sr-only" for="authPassword">Password</Label>
<Input
id="authPassword"
type="password"
placeholder="Password"
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500"
value={$appState.authForm.password}
oninput={(event) => appController.setAuthField('password', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{#if $appState.isRegistering}
<div id="authNameField" class="flex flex-col gap-2">
<Label class="sr-only" for="authName">Name</Label>
<Input
id="authName"
type="text"
placeholder="Your Name"
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500"
value={$appState.authForm.name}
oninput={(event) => appController.setAuthField('name', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{/if}
</CardContent>
<CardFooter class="flex flex-col items-stretch gap-4 border-0 bg-transparent px-6 pb-6 pt-2">
<Button
id="signInBtn"
class="btn-premium h-12 w-full rounded-xl text-base text-white shadow-lg shadow-blue-900/20"
onclick={() => appController.submitAuth()}
>
{$appState.isRegistering ? 'Create Account' : 'Sign In'}
</button>
<div class="divider text-xs text-gray-600">OR</div>
<button
</Button>
<div class="flex w-full items-center gap-3 text-xs text-gray-600">
<Separator class="flex-1 bg-white/10" />
<span>OR</span>
<Separator class="flex-1 bg-white/10" />
</div>
<Button
id="toggleAuthModeBtn"
class="btn btn-ghost w-full text-gray-400 hover:text-white hover:bg-white/5 rounded-xl border border-white/5"
variant="ghost"
class="h-12 w-full rounded-xl border border-white/5 text-gray-400 hover:bg-white/5 hover:text-white"
onclick={() => appController.toggleAuthMode()}
>
{$appState.isRegistering ? 'I already have an account' : 'Create an account'}
</button>
</div>
</div>
</Button>
</CardFooter>
</Card>
</section>
</AppChrome>

View File

@@ -3,25 +3,35 @@
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
</script>
<AppChrome pageKey="activity">
<section id="screen-activity" class="flex flex-col gap-6 max-w-4xl mx-auto py-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-white tracking-tight">Activity History</h2>
<button
<Button
id="clearActivityBtn"
class="btn btn-ghost text-gray-400 hover:bg-white/5 hover:text-white rounded-xl border border-transparent"
variant="ghost"
class="rounded-xl border border-transparent text-gray-400 hover:bg-white/5 hover:text-white"
onclick={() => appController.clearNotifications()}
>
Clear Read
</button>
</Button>
</div>
<div class="glass-card rounded-3xl border border-white/5 p-2 overflow-hidden">
<div id="activityFeedList" class="divide-y divide-white/5">
<Card class="glass-card rounded-3xl border-white/10 bg-[#16161d]/80">
<CardHeader>
<CardTitle class="text-sm font-semibold tracking-wide text-white">Motion Alerts</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[32rem] pr-3">
<div id="activityFeedList" class="flex flex-col gap-3">
{#if $appState.motionNotifications.length === 0}
<div class="text-center py-16 opacity-50">
<Alert class="border-white/10 bg-black/20 text-center opacity-70">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@@ -30,21 +40,27 @@
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<p class="text-sm font-medium text-gray-400">All quiet. No notifications yet.</p>
</div>
<AlertTitle class="justify-self-center text-sm text-gray-200">All quiet</AlertTitle>
<AlertDescription class="justify-self-center text-sm text-gray-400">
No notifications yet.
</AlertDescription>
</Alert>
{:else}
{#each $appState.motionNotifications as notification (notification.id)}
<button
type="button"
class="w-full text-left p-3 rounded-lg border border-white/5 {notification.isRead ? 'bg-gray-900/30' : 'bg-blue-900/20'}"
<Button
type="button"
variant="ghost"
class="h-auto w-full flex-col items-start gap-1 rounded-xl border border-white/5 px-4 py-3 text-left {notification.isRead ? 'bg-gray-900/30 text-gray-200' : 'bg-blue-900/20 text-white'}"
onclick={() => appController.openMotionNotificationTarget(notification.id, notification.cameraDeviceId)}
>
<p class="text-xs font-medium text-gray-200">{notification.message}</p>
<p class="text-[10px] text-gray-500 mt-1">{new Date(notification.createdAt).toLocaleString()}</p>
</button>
<p class="text-xs font-medium">{notification.message}</p>
<p class="text-[10px] text-gray-500">{new Date(notification.createdAt).toLocaleString()}</p>
</Button>
{/each}
{/if}
</div>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</section>
</AppChrome>

View File

@@ -38,9 +38,9 @@
</script>
<AppChrome pageKey="camera">
<section id="screen-home-camera" class="flex flex-col gap-6 max-w-7xl mx-auto h-full min-h-0">
<div class="flex-1 min-h-0 flex flex-col gap-6">
<Card class="glass-card relative flex min-h-[260px] flex-[3] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80">
<section id="screen-home-camera" class="mx-auto flex h-full min-h-0 max-w-7xl flex-col gap-4 lg:gap-6">
<div class="flex flex-col gap-4 lg:gap-6 xl:flex-1 xl:min-h-0">
<Card class="glass-card relative flex min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 sm:min-h-[260px] xl:flex-[3]">
<div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center {$appState.isMotionActive ? 'bg-red-900/20' : ''}">
<video
id="cameraVideo"
@@ -90,16 +90,16 @@
</div>
</Card>
<div class="flex-[2] min-h-0 grid grid-cols-1 xl:grid-cols-[2fr_1fr] gap-6">
<Card class="glass-card min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80">
<div class="grid grid-cols-1 gap-4 lg:gap-6 xl:min-h-0 xl:flex-[2] xl:grid-cols-[2fr_1fr]">
<Card class="glass-card min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 xl:min-h-0">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Logs</CardTitle>
</CardHeader>
<CardContent class="flex-1">
<CardContent class="flex-1 xl:min-h-0">
<ScrollArea
id="cameraLogs"
class="min-h-[220px] rounded-xl border border-white/5 bg-black/40 p-4 text-xs font-mono text-gray-400"
>
id="cameraLogs"
class="h-[220px] rounded-xl border border-white/5 bg-black/40 p-4 text-xs font-mono text-gray-400 xl:h-full xl:min-h-[220px]"
>
{#if $appState.activityLog.length === 0}
<div class="text-gray-600 italic">Awaiting connection...</div>
{:else}
@@ -111,11 +111,11 @@
</CardContent>
</Card>
<Card class="glass-card min-h-[220px] rounded-3xl border-white/10 bg-[#16161d]/80">
<Card class="glass-card min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 xl:min-h-0">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Actions / Options</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<CardContent class="space-y-4 xl:min-h-0 xl:overflow-y-auto">
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="cameraInputSelect" class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
@@ -150,8 +150,8 @@
</SelectContent>
</Select>
</div>
<div class="rounded-2xl border border-white/10 bg-black/30 p-4 space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="space-y-3 rounded-2xl border border-white/10 bg-black/30 p-3 sm:p-4 sm:space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">Automatic Detection</p>
<p class="text-sm text-gray-200">
@@ -163,7 +163,7 @@
</div>
<Button
variant={$appState.motionDetection.enabled ? 'secondary' : 'outline'}
class="min-w-[112px] rounded-xl"
class="min-w-[112px] self-start rounded-xl sm:self-auto"
onclick={() => appController.setMotionDetectionEnabled(!$appState.motionDetection.enabled)}
>
{$appState.motionDetection.enabled ? 'Pause' : 'Arm'}
@@ -197,7 +197,7 @@
</Select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-xl border border-white/5 bg-white/5 px-3 py-2">
<p class="text-[10px] uppercase tracking-wider text-gray-500">Detector State</p>
<p class="mt-1 text-sm font-medium text-gray-200">{$appState.motionDetection.state}</p>

View File

@@ -3,6 +3,12 @@
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '$lib/components/ui/dropdown-menu/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
let clientVideoElement: HTMLVideoElement | null = null;
@@ -25,26 +31,27 @@
};
</script>
<svelte:window onclick={() => appController.closeLinkedCameraMenu()} />
<AppChrome pageKey="client">
<section id="screen-home-client" class="flex flex-col gap-12 max-w-7xl mx-auto h-full">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-white tracking-tight">Client Dashboard</h2>
<div class="flex gap-3">
<button
<Button
id="linkCameraBtn"
class="btn btn-outline border-white/10 text-gray-300 hover:text-white hover:bg-white/5 rounded-xl gap-2 shadow-none"
variant="outline"
class="rounded-xl border-white/10 text-gray-300 shadow-none hover:bg-white/5 hover:text-white"
onclick={() => appController.linkCamera()}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Link Camera
</button>
<button
</Button>
<Button
id="refreshClientBtn"
class="btn btn-ghost btn-square rounded-xl bg-white/5 border border-white/5 hover:bg-white/10"
variant="ghost"
size="icon"
class="rounded-xl border border-white/5 bg-white/5 hover:bg-white/10"
aria-label="Refresh linked cameras"
title="Refresh linked cameras"
onclick={() => appController.refreshClientData()}
@@ -57,128 +64,155 @@
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</Button>
</div>
</div>
<div class="glass-card rounded-3xl border border-white/5 p-5 shrink-0 mb-4">
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Your Cameras</h3>
<div id="linkedCamerasList" class="flex overflow-x-auto gap-4 pb-2 snap-x">
<Card class="glass-card mb-4 shrink-0 rounded-3xl border-white/10 bg-[#16161d]/80">
<CardHeader>
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-400">Your Cameras</CardTitle>
</CardHeader>
<CardContent>
{#if $appState.linkedCameras.length === 0}
<div class="w-full text-center py-6 bg-black/20 rounded-2xl border border-dashed border-white/10">
<p class="text-gray-500 text-sm">No cameras linked yet</p>
</div>
<Alert class="border-dashed border-white/10 bg-black/20 text-center">
<AlertTitle class="justify-self-center text-sm text-gray-200">No cameras linked</AlertTitle>
<AlertDescription class="justify-self-center text-sm text-gray-500">
Link a camera to start viewing live feeds and recordings.
</AlertDescription>
</Alert>
{:else}
{#each $appState.linkedCameras as link (link.id)}
<div
role="button"
tabindex="0"
class="min-w-[240px] max-w-[240px] bg-gray-900/60 rounded-xl border border-white/5 overflow-hidden flex-shrink-0 cursor-pointer hover:border-blue-500/50 transition-colors text-left"
onclick={() => appController.selectCamera(link.cameraDeviceId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') appController.selectCamera(link.cameraDeviceId);
}}
>
<div class="relative overflow-hidden bg-black/40 border-b border-white/5 aspect-video">
{#if $appState.activeCameraDeviceId === link.cameraDeviceId}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-blue-400 bg-blue-500/10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<p class="text-[10px] font-medium uppercase tracking-wider">Viewing</p>
<ScrollArea class="w-full whitespace-nowrap" orientation="horizontal">
<div id="linkedCamerasList" class="flex gap-4 pb-2">
{#each $appState.linkedCameras as link (link.id)}
<div
role="button"
tabindex="0"
class="min-w-[240px] max-w-[240px] flex-shrink-0 cursor-pointer text-left"
onclick={(event) => {
if ((event.target as HTMLElement).closest('[data-menu-trigger]')) return;
appController.selectCamera(link.cameraDeviceId);
}}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') appController.selectCamera(link.cameraDeviceId);
}}
>
<Card
size="sm"
class="overflow-hidden rounded-xl border transition-colors { $appState.activeCameraDeviceId === link.cameraDeviceId ? 'border-blue-500/50 bg-blue-950/20' : 'border-white/5 bg-gray-900/60 hover:border-blue-500/30' }"
>
<div class="relative aspect-video overflow-hidden border-b border-white/5 bg-black/40">
{#if $appState.activeCameraDeviceId === link.cameraDeviceId}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-blue-500/10 text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<Badge variant="secondary" class="rounded-full px-2 text-[10px] uppercase tracking-wider">
Viewing
</Badge>
</div>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500 transition-colors hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p class="text-[10px]">{isCameraLive(link.cameraDeviceId) ? 'Live Stream Active' : 'Click to view'}</p>
</div>
{/if}
</div>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500 hover:text-gray-300 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p class="text-[10px]">{isCameraLive(link.cameraDeviceId) ? 'Live Stream Active' : 'Click to view'}</p>
</div>
{/if}
</div>
<div class="px-3 py-3">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0 {statusDotClass(link.cameraStatus)}"></div>
<div class="min-w-0">
<p class="text-xs font-bold text-gray-200 truncate" title={link.cameraName || link.cameraDeviceId}>
{link.cameraName || link.cameraDeviceId}
</p>
<p class="text-[10px] text-gray-400 capitalize">{link.cameraStatus || 'offline'}</p>
<CardContent class="px-3 py-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1 space-y-2">
<p class="truncate text-xs font-bold text-gray-200" title={link.cameraName || link.cameraDeviceId}>
{link.cameraName || link.cameraDeviceId}
</p>
<Badge
variant={link.cameraStatus?.toLowerCase() === 'online' ? 'secondary' : 'outline'}
class="gap-1.5 rounded-full px-2 capitalize"
>
<span class="h-2 w-2 rounded-full shrink-0 {statusDotClass(link.cameraStatus)}"></span>
{link.cameraStatus || 'offline'}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button
data-menu-trigger
variant="ghost"
size="icon-xs"
class="text-gray-400 hover:text-white"
{...props}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<circle cx="10" cy="4" r="1.6" />
<circle cx="10" cy="10" r="1.6" />
<circle cx="10" cy="16" r="1.6" />
</svg>
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-28">
<DropdownMenuItem
onclick={(event) => {
event.stopPropagation();
appController.renameLinkedCamera(link.cameraDeviceId);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onclick={(event) => {
event.stopPropagation();
appController.deleteLinkedCamera(link.id);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div class="relative">
<button
type="button"
class="btn btn-ghost btn-xs btn-circle text-gray-400 hover:text-white"
aria-label="Camera options"
onclick={(event) => {
event.stopPropagation();
appController.toggleLinkedCameraMenu(link.id);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<circle cx="10" cy="4" r="1.6" />
<circle cx="10" cy="10" r="1.6" />
<circle cx="10" cy="16" r="1.6" />
</svg>
</button>
<div class="{($appState.openLinkedCameraMenuId === link.id ? '' : 'hidden ')}absolute right-0 bottom-8 z-20 w-28 overflow-hidden rounded-lg border border-white/10 bg-gray-900/95 shadow-xl backdrop-blur">
<button
type="button"
class="w-full px-3 py-2 text-left text-xs text-gray-200 hover:bg-white/10"
onclick={(event) => {
event.stopPropagation();
appController.renameLinkedCamera(link.cameraDeviceId);
}}
>
Rename
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-xs text-red-300 hover:bg-red-500/10"
onclick={(event) => {
event.stopPropagation();
appController.deleteLinkedCamera(link.id);
}}
>
Delete
</button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/each}
</div>
{/each}
</ScrollArea>
{/if}
</div>
</div>
</CardContent>
</Card>
<div class="flex flex-col xl:flex-row gap-8 flex-1 min-h-0">
<div
<Card
id="clientStreamViewerWrapper"
class="flex-1 glass-card rounded-3xl overflow-hidden border border-white/10 flex flex-col shadow-xl min-h-[400px] {$appState.activeCameraDeviceId ? '' : 'hidden'}"
class="glass-card min-h-[400px] flex-1 overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 shadow-xl {$appState.activeCameraDeviceId ? '' : 'hidden'}"
>
<div class="px-5 py-4 border-b border-white/5 bg-black/20 flex justify-between items-center">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-red-500 animate-[pulse_2s_ease-in-out_infinite] {isCameraLive($appState.activeCameraDeviceId) ? '' : 'hidden'}" id="clientLiveDot"></span>
<h3 class="font-medium text-white tracking-wide" id="clientStreamViewerTitle">Live Feed: {activeCameraLabel()}</h3>
</div>
<button
<CardHeader class="border-b border-white/5 bg-black/20 px-5 py-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-red-500 animate-[pulse_2s_ease-in-out_infinite] {isCameraLive($appState.activeCameraDeviceId) ? '' : 'hidden'}" id="clientLiveDot"></span>
<CardTitle class="font-medium tracking-wide text-white" id="clientStreamViewerTitle">
Live Feed: {activeCameraLabel()}
</CardTitle>
</div>
<Button
id="closeStreamViewerBtn"
class="btn btn-ghost btn-sm btn-circle text-gray-400 hover:text-white"
variant="ghost"
size="icon-sm"
class="text-gray-400 hover:text-white"
aria-label="Close stream viewer"
title="Close stream viewer"
onclick={() => appController.closeStreamViewer()}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Button>
</div>
</CardHeader>
<div id="clientStreamContainer" class="flex-1 bg-black relative flex items-center justify-center">
<CardContent id="clientStreamContainer" class="flex flex-1 items-center justify-center bg-black">
<video
id="clientStreamVideo"
class="absolute inset-0 w-full h-full object-contain {$appState.clientStreamMode === 'video' ? '' : 'hidden'}"
@@ -186,7 +220,10 @@
bind:this={clientVideoElement}
></video>
<div id="clientStreamPlaceholder" class="flex flex-col items-center gap-4 animate-pulse {$appState.clientStreamMode === 'none' || $appState.clientStreamMode === 'connecting' || $appState.clientStreamMode === 'unavailable' ? '' : 'hidden'}">
<Alert
id="clientStreamPlaceholder"
class="flex max-w-md flex-col items-center gap-4 border-white/10 bg-black/40 text-center {$appState.clientStreamMode === 'none' || $appState.clientStreamMode === 'connecting' || $appState.clientStreamMode === 'unavailable' ? '' : 'hidden'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@@ -195,40 +232,56 @@
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
<p class="text-sm font-medium text-gray-500 tracking-wide uppercase">{$appState.clientPlaceholderText}</p>
</div>
</div>
</div>
<AlertTitle class="justify-self-center text-sm text-gray-200">Stream unavailable</AlertTitle>
<AlertDescription class="justify-self-center text-sm font-medium uppercase tracking-wide text-gray-500">
{$appState.clientPlaceholderText}
</AlertDescription>
</Alert>
</CardContent>
</Card>
<div class="xl:w-96 shrink-0 flex flex-col gap-6 overflow-y-auto pr-2">
<div class="glass-card rounded-3xl border border-white/5 p-5 flex-1">
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Recent Recordings</h3>
<div id="recordingsList" class="space-y-3">
<Card class="glass-card flex-1 rounded-3xl border-white/10 bg-[#16161d]/80">
<CardHeader>
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-400">Recent Recordings</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[26rem] pr-3">
<div id="recordingsList" class="space-y-3">
{#if $appState.recordings.length === 0}
<div class="text-center py-4 bg-gray-900/30 rounded-xl">
<p class="text-gray-600 text-xs text-center">No recordings found</p>
</div>
<Alert class="border-white/10 bg-gray-900/30 text-center">
<AlertTitle class="justify-self-center text-xs text-gray-200">No recordings found</AlertTitle>
<AlertDescription class="justify-self-center text-xs text-gray-500">
Captured clips will appear here when they are ready.
</AlertDescription>
</Alert>
{:else}
{#each $appState.recordings.slice(0, 5) as recording (recording.id)}
<div class="flex items-center justify-between p-3 bg-gray-900/40 rounded-lg border border-white/5 hover:bg-gray-800 transition-colors">
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-300">{new Date(recording.createdAt).toLocaleString()}</span>
<span class="text-[10px] text-gray-500">
{recording.durationSeconds != null ? `${recording.durationSeconds}s duration` : 'Duration pending'} · {recording.status ?? 'unknown'}
</span>
</div>
<button
class="btn btn-xs btn-outline border-white/10 text-gray-400"
disabled={recording.status !== 'ready'}
onclick={() => appController.openRecording(recording.id)}
>
View
</button>
</div>
<Card size="sm" class="border-white/5 bg-gray-900/40">
<CardContent class="flex items-center justify-between gap-4 px-3 py-3">
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-300">{new Date(recording.createdAt).toLocaleString()}</span>
<span class="text-[10px] text-gray-500">
{recording.durationSeconds != null ? `${recording.durationSeconds}s duration` : 'Duration pending'} · {recording.status ?? 'unknown'}
</span>
</div>
<Button
variant="outline"
size="xs"
class="border-white/10 text-gray-400"
disabled={recording.status !== 'ready'}
onclick={() => appController.openRecording(recording.id)}
>
View
</Button>
</CardContent>
</Card>
{/each}
{/if}
</div>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</section>

View File

@@ -1,81 +0,0 @@
@import 'tailwindcss';
@plugin "daisyui";
body {
font-family: 'Inter', sans-serif;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgb(0 0 0 / 20%);
}
::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 10%);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(255 255 255 / 20%);
}
.glass-panel {
background: rgb(15 15 20 / 70%);
backdrop-filter: blur(16px);
border: 1px solid rgb(255 255 255 / 5%);
}
.glass-card {
background: rgb(25 25 30 / 60%);
border: 1px solid rgb(255 255 255 / 8%);
}
.btn-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
color: white;
transition: all 0.2s ease;
}
.btn-premium:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
}
.status-dot {
height: 8px;
width: 8px;
border-radius: 50%;
display: inline-block;
}
.status-online {
background-color: #10b981;
box-shadow: 0 0 8px rgb(16 185 129 / 40%);
}
.status-offline {
background-color: #ef4444;
box-shadow: 0 0 8px rgb(239 68 68 / 40%);
}
@keyframes slideIn {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.toast-enter {
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

View File

@@ -3,74 +3,90 @@
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group/index.js';
</script>
<AppChrome pageKey="onboarding">
<section id="screen-onboarding" class="flex flex-col items-center justify-center min-h-[70vh] max-w-lg mx-auto">
<div class="text-center space-y-2 mb-8">
<h2 class="text-3xl font-bold text-white tracking-tight">Configure Device</h2>
<p class="text-sm text-gray-400">Set up this browser dashboard role</p>
</div>
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-6 shadow-2xl">
<div class="form-control w-full">
<label class="label" for="deviceName"
><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">DEVICE NAME</span></label
>
<input
id="deviceName"
type="text"
placeholder="e.g. Living Room Cam"
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500"
value={$appState.onboardingForm.name}
oninput={(event) => appController.setOnboardingField('name', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="form-control w-full">
<label class="label" for="role"
><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">ROLE</span></label
>
<div class="grid grid-cols-2 gap-2 p-1.5 bg-black/40 rounded-xl border border-white/5">
<button
class="btn btn-ghost normal-case rounded-lg h-10 min-h-0 {$appState.onboardingForm.role === 'camera' ? 'bg-blue-600 text-white' : 'text-gray-400'}"
id="btn-role-camera"
onclick={() => appController.selectRole('camera')}
>
Camera
</button>
<button
class="btn btn-ghost normal-case rounded-lg h-10 min-h-0 {$appState.onboardingForm.role === 'client' ? 'bg-blue-600 text-white' : 'text-gray-400'}"
id="btn-role-client"
onclick={() => appController.selectRole('client')}
>
Client
</button>
<Card class="glass-card w-full rounded-3xl border-white/10 bg-[#16161d]/80 shadow-2xl">
<CardHeader class="px-6 pt-6 text-center">
<CardTitle class="text-3xl font-bold tracking-tight text-white">Configure Device</CardTitle>
<CardDescription class="text-sm text-gray-400">Set up this browser dashboard role</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-6 px-6">
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold tracking-wider text-gray-400" for="deviceName">DEVICE NAME</Label>
<Input
id="deviceName"
type="text"
placeholder="e.g. Living Room Cam"
class="h-12 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-gray-500"
value={$appState.onboardingForm.name}
oninput={(event) => appController.setOnboardingField('name', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
</div>
<div class="form-control w-full">
<label class="label" for="pushToken"
><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">PUSH TOKEN (OPTIONAL)</span></label
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold tracking-wider text-gray-400" for="role">ROLE</Label>
<ToggleGroup
id="role"
type="single"
value={$appState.onboardingForm.role}
variant="outline"
size="lg"
class="grid w-full grid-cols-2 gap-2 rounded-xl border border-white/5 bg-black/40 p-1.5"
onValueChange={(value) => value && appController.selectRole(value)}
>
<ToggleGroupItem
id="btn-role-camera"
value="camera"
class="h-10 justify-center rounded-lg border-white/0 text-sm font-medium text-gray-400 data-[state=on]:border-blue-500/30 data-[state=on]:bg-blue-600 data-[state=on]:text-white"
>
Camera
</ToggleGroupItem>
<ToggleGroupItem
id="btn-role-client"
value="client"
class="h-10 justify-center rounded-lg border-white/0 text-sm font-medium text-gray-400 data-[state=on]:border-blue-500/30 data-[state=on]:bg-blue-600 data-[state=on]:text-white"
>
Client
</ToggleGroupItem>
</ToggleGroup>
</div>
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold tracking-wider text-gray-400" for="pushToken">PUSH TOKEN (OPTIONAL)</Label>
<Input
id="pushToken"
type="text"
placeholder="simulated_token_123"
class="h-12 rounded-xl border-white/10 bg-black/40 text-white placeholder:text-gray-500"
value={$appState.onboardingForm.pushToken}
oninput={(event) => appController.setOnboardingField('pushToken', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
</CardContent>
<CardFooter class="flex flex-col items-stretch gap-3 border-0 bg-transparent px-6 pb-6 pt-2">
<Button
id="registerBtn"
class="btn-premium h-12 w-full rounded-xl text-base text-white"
onclick={() => appController.registerDevice()}
>
<input
id="pushToken"
type="text"
placeholder="simulated_token_123"
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500"
value={$appState.onboardingForm.pushToken}
oninput={(event) => appController.setOnboardingField('pushToken', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="pt-6">
<button id="registerBtn" class="btn btn-premium w-full h-12 rounded-xl text-base" onclick={() => appController.registerDevice()}>
Complete Setup
</button>
<button id="loadSavedBtn" class="btn btn-ghost w-full mt-3 text-gray-500 hover:text-white hover:bg-white/5" onclick={() => appController.loadSavedDevice()}>
</Button>
<Button
id="loadSavedBtn"
variant="ghost"
class="h-12 w-full rounded-xl text-gray-500 hover:bg-white/5 hover:text-white"
onclick={() => appController.loadSavedDevice()}
>
Load previously saved device
</button>
</div>
</div>
</Button>
</CardFooter>
</Card>
</section>
</AppChrome>

View File

@@ -7,7 +7,7 @@ describe('/+page.svelte', () => {
it('should render simulator auth heading', async () => {
render(Page);
const heading = page.getByRole('heading', { name: 'SecureCam Web' });
const heading = page.getByRole('heading', { name: 'PhoneCam Web' });
await expect.element(heading).toBeInTheDocument();
});
});

View File

@@ -3,6 +3,10 @@
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
const profileName = () => $appState.session?.user?.name || 'User';
const profileEmail = () => $appState.session?.user?.email || 'user@example.com';
@@ -13,23 +17,27 @@
<section id="screen-settings" class="flex flex-col gap-6 max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-white tracking-tight px-2">Settings</h2>
<div class="glass-card rounded-3xl border border-white/5 p-8 flex items-center gap-6">
<div
class="w-20 h-20 bg-blue-600/20 text-blue-500 rounded-full flex items-center justify-center leading-none text-2xl font-bold border border-blue-500/30"
id="profileInitials"
>
{profileInitial()}
</div>
<div>
<h3 class="text-xl text-white font-semibold tracking-wide" id="profileName">{profileName()}</h3>
<p class="text-gray-400 mt-1" id="profileEmail">{profileEmail()}</p>
</div>
</div>
<Card class="glass-card rounded-3xl border-white/10 bg-[#16161d]/80">
<CardContent class="flex items-center gap-6">
<Avatar id="profileInitials" size="lg" class="size-20 bg-blue-600/20 text-2xl font-bold text-blue-500">
<AvatarFallback>{profileInitial()}</AvatarFallback>
</Avatar>
<div>
<h3 class="text-xl font-semibold tracking-wide text-white" id="profileName">{profileName()}</h3>
<p class="mt-1 text-gray-400" id="profileEmail">{profileEmail()}</p>
</div>
</CardContent>
</Card>
<div class="glass-card rounded-3xl border border-white/5 overflow-hidden">
<button
<Card class="glass-card overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80">
<CardHeader>
<CardTitle class="text-sm font-semibold tracking-wide text-white">Tools</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-0 p-0">
<Button
id="checkOpsBtn"
class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors border-b border-white/5 group"
variant="ghost"
class="group h-auto w-full justify-between rounded-none px-5 py-5 text-left text-gray-300 hover:bg-white/5"
onclick={() => appController.runDiagnostics()}
>
<div class="flex items-center gap-4">
@@ -45,11 +53,12 @@
</div>
<span class="font-medium">Run Diagnostics</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg data-icon="inline-end" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors group">
</Button>
<Separator class="bg-white/5" />
<Button variant="ghost" class="group h-auto w-full justify-between rounded-none px-5 py-5 text-left text-gray-300 hover:bg-white/5">
<div class="flex items-center gap-4">
<div class="p-2 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -64,18 +73,20 @@
</div>
<span class="font-medium">Device Information</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg data-icon="inline-end" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</Button>
</CardContent>
</Card>
<button
<Button
id="signOutBtn"
class="btn btn-error btn-outline w-full rounded-2xl h-14 text-base font-medium gap-3 border-red-500/50 hover:bg-red-500/10 hover:border-red-500 mt-4"
variant="destructive"
class="mt-4 h-14 w-full rounded-2xl border border-red-500/50 text-base font-medium hover:border-red-500 hover:bg-red-500/10"
onclick={() => appController.signOut()}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -84,6 +95,6 @@
/>
</svg>
Sign Out
</button>
</Button>
</section>
</AppChrome>