feat(webapp): redesign dashboard with shadcn-style UI shell
This commit is contained in:
199
WebApp/src/app.css
Normal file
199
WebApp/src/app.css
Normal 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;
|
||||
}
|
||||
20
WebApp/src/lib/components/ui/alert/alert-action.svelte
Normal file
20
WebApp/src/lib/components/ui/alert/alert-action.svelte
Normal 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>
|
||||
23
WebApp/src/lib/components/ui/alert/alert-description.svelte
Normal file
23
WebApp/src/lib/components/ui/alert/alert-description.svelte
Normal 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>
|
||||
23
WebApp/src/lib/components/ui/alert/alert-title.svelte
Normal file
23
WebApp/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
43
WebApp/src/lib/components/ui/alert/alert.svelte
Normal file
43
WebApp/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
17
WebApp/src/lib/components/ui/alert/index.ts
Normal file
17
WebApp/src/lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
26
WebApp/src/lib/components/ui/avatar/avatar-badge.svelte
Normal file
26
WebApp/src/lib/components/ui/avatar/avatar-badge.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
20
WebApp/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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>
|
||||
23
WebApp/src/lib/components/ui/avatar/avatar-group.svelte
Normal file
23
WebApp/src/lib/components/ui/avatar/avatar-group.svelte
Normal 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>
|
||||
17
WebApp/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
WebApp/src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
26
WebApp/src/lib/components/ui/avatar/avatar.svelte
Normal file
26
WebApp/src/lib/components/ui/avatar/avatar.svelte
Normal 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}
|
||||
/>
|
||||
22
WebApp/src/lib/components/ui/avatar/index.ts
Normal file
22
WebApp/src/lib/components/ui/avatar/index.ts
Normal 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,
|
||||
};
|
||||
49
WebApp/src/lib/components/ui/badge/badge.svelte
Normal file
49
WebApp/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
WebApp/src/lib/components/ui/badge/index.ts
Normal file
2
WebApp/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
WebApp/src/lib/components/ui/button/button.svelte
Normal file
82
WebApp/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
WebApp/src/lib/components/ui/button/index.ts
Normal file
17
WebApp/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
23
WebApp/src/lib/components/ui/card/card-action.svelte
Normal file
23
WebApp/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/card/card-content.svelte
Normal file
20
WebApp/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/card/card-description.svelte
Normal file
20
WebApp/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/card/card-footer.svelte
Normal file
20
WebApp/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
23
WebApp/src/lib/components/ui/card/card-header.svelte
Normal file
23
WebApp/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/card/card-title.svelte
Normal file
20
WebApp/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
22
WebApp/src/lib/components/ui/card/card.svelte
Normal file
22
WebApp/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
WebApp/src/lib/components/ui/card/index.ts
Normal file
25
WebApp/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
11
WebApp/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
11
WebApp/src/lib/components/ui/dialog/dialog-close.svelte
Normal 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} />
|
||||
48
WebApp/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
48
WebApp/src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
32
WebApp/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
32
WebApp/src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
WebApp/src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
17
WebApp/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
17
WebApp/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
7
WebApp/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
WebApp/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
WebApp/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
WebApp/src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
11
WebApp/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
11
WebApp/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal 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} />
|
||||
7
WebApp/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
WebApp/src/lib/components/ui/dialog/dialog.svelte
Normal 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} />
|
||||
34
WebApp/src/lib/components/ui/dialog/index.ts
Normal file
34
WebApp/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Root from "./dialog.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable([]),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-checkbox-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Root bind:open {...restProps} />
|
||||
54
WebApp/src/lib/components/ui/dropdown-menu/index.ts
Normal file
54
WebApp/src/lib/components/ui/dropdown-menu/index.ts
Normal 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,
|
||||
};
|
||||
7
WebApp/src/lib/components/ui/input/index.ts
Normal file
7
WebApp/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
48
WebApp/src/lib/components/ui/input/input.svelte
Normal file
48
WebApp/src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
7
WebApp/src/lib/components/ui/label/index.ts
Normal file
7
WebApp/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
WebApp/src/lib/components/ui/label/label.svelte
Normal file
20
WebApp/src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
10
WebApp/src/lib/components/ui/scroll-area/index.ts
Normal file
10
WebApp/src/lib/components/ui/scroll-area/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
43
WebApp/src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
43
WebApp/src/lib/components/ui/scroll-area/scroll-area.svelte
Normal 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>
|
||||
37
WebApp/src/lib/components/ui/select/index.ts
Normal file
37
WebApp/src/lib/components/ui/select/index.ts
Normal 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,
|
||||
};
|
||||
45
WebApp/src/lib/components/ui/select/select-content.svelte
Normal file
45
WebApp/src/lib/components/ui/select/select-content.svelte
Normal 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>
|
||||
@@ -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>
|
||||
17
WebApp/src/lib/components/ui/select/select-group.svelte
Normal file
17
WebApp/src/lib/components/ui/select/select-group.svelte
Normal 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}
|
||||
/>
|
||||
38
WebApp/src/lib/components/ui/select/select-item.svelte
Normal file
38
WebApp/src/lib/components/ui/select/select-item.svelte
Normal 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>
|
||||
20
WebApp/src/lib/components/ui/select/select-label.svelte
Normal file
20
WebApp/src/lib/components/ui/select/select-label.svelte
Normal 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>
|
||||
7
WebApp/src/lib/components/ui/select/select-portal.svelte
Normal file
7
WebApp/src/lib/components/ui/select/select-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { 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>
|
||||
@@ -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>
|
||||
18
WebApp/src/lib/components/ui/select/select-separator.svelte
Normal file
18
WebApp/src/lib/components/ui/select/select-separator.svelte
Normal 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}
|
||||
/>
|
||||
29
WebApp/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
WebApp/src/lib/components/ui/select/select-trigger.svelte
Normal 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>
|
||||
11
WebApp/src/lib/components/ui/select/select.svelte
Normal file
11
WebApp/src/lib/components/ui/select/select.svelte
Normal 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} />
|
||||
7
WebApp/src/lib/components/ui/separator/index.ts
Normal file
7
WebApp/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
23
WebApp/src/lib/components/ui/separator/separator.svelte
Normal file
23
WebApp/src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
10
WebApp/src/lib/components/ui/toggle-group/index.ts
Normal file
10
WebApp/src/lib/components/ui/toggle-group/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
13
WebApp/src/lib/components/ui/toggle/index.ts
Normal file
13
WebApp/src/lib/components/ui/toggle/index.ts
Normal 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,
|
||||
};
|
||||
51
WebApp/src/lib/components/ui/toggle/toggle.svelte
Normal file
51
WebApp/src/lib/components/ui/toggle/toggle.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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
13
WebApp/src/lib/utils.ts
Normal 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 };
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user