feat(web): refresh simulator dashboard UI

This commit is contained in:
2026-04-16 14:45:00 +01:00
parent 68ecc82bd9
commit 3c1099efdf
17 changed files with 407 additions and 407 deletions

View File

@@ -38,7 +38,6 @@
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
@@ -109,6 +108,13 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Custom brand colors and glassmorphism tokens */
--color-premium: #2563eb;
--color-premium-foreground: #ffffff;
--color-glass-background: rgb(25 25 30 / 60%);
--color-glass-border: rgb(255 255 255 / 8%);
--color-glass-panel: rgb(15 15 20 / 70%);
} }
@layer base { @layer base {
@@ -123,7 +129,7 @@
} }
} }
/* Preserve existing simulator utility styles alongside shadcn tokens. */ /* Scrollbar customizations */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -142,29 +148,6 @@
background: rgb(255 255 255 / 20%); 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 { .status-dot {
height: 8px; height: 8px;
width: 8px; width: 8px;
@@ -193,7 +176,3 @@
opacity: 1; opacity: 1;
} }
} }
.toast-enter {
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

View File

@@ -805,39 +805,6 @@ const stopPolling = () => {
} }
}; };
const pollClientData = async () => {
const { device } = getAppState();
if (!device || device.role !== 'client') return;
const [recs, links, deviceList, notifications] = await Promise.all([
api.ops.listRecordings().catch(() => ({ recordings: [] })),
api.devices.listLinks().catch(() => ({ links: [] })),
api.devices.list().catch(() => ({ devices: [] })),
api.ops.listNotifications().catch(() => ({ notifications: [] }))
]);
const cameraById = new Map(
(deviceList.devices || [])
.filter((entry) => entry.role === 'camera')
.map((entry) => [entry.id, entry])
);
const linkedCameras = (links.links || []).map((link) => {
const camera = cameraById.get(link.cameraDeviceId);
return {
...link,
cameraName: camera?.name ?? null,
cameraStatus: camera?.status ?? 'offline'
};
});
setAppState({
recordings: recs.recordings || [],
linkedCameras
});
syncMotionNotificationsFromDeliveries(notifications.notifications);
};
const startPolling = () => { const startPolling = () => {
stopPolling(); stopPolling();
void pollClientData(); void pollClientData();

View File

@@ -7,6 +7,7 @@
variant: { variant: {
default: "bg-card text-card-foreground", default: "bg-card text-card-foreground",
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
glass: "bg-glass-background border-glass-border backdrop-blur-xl shadow-lg",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -13,6 +13,7 @@
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-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", 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", link: "text-primary underline-offset-4 hover:underline",
premium: "bg-premium text-premium-foreground shadow-lg shadow-premium/20 transition-all hover:-translate-y-0.5 hover:shadow-xl hover:shadow-premium/30 active:translate-y-0",
}, },
size: { size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",

View File

@@ -1,21 +1,51 @@
<script lang="ts"> <script lang="ts" module>
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const cardVariants = tv({
base: "bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
variants: {
variant: {
default: "bg-card text-card-foreground",
glass: "bg-glass-background border-glass-border border ring-0 backdrop-blur-xl shadow-2xl",
},
size: {
default: "gap-4 py-4 data-[size=sm]:gap-3 data-[size=sm]:py-3",
sm: "gap-3 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type CardVariant = VariantProps<typeof cardVariants>["variant"];
export type CardSize = VariantProps<typeof cardVariants>["size"];
export type CardProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: CardVariant;
size?: CardSize;
};
</script>
<script lang="ts">
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children, children,
variant = "default",
size = "default", size = "default",
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props(); }: CardProps = $props();
</script> </script>
<div <div
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
data-size={size} 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)} class={cn(cardVariants({ variant, size }), className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -14,12 +14,19 @@
portalProps, portalProps,
children, children,
showCloseButton = true, showCloseButton = true,
variant = "default",
...restProps ...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & { }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>; portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet; children: Snippet;
showCloseButton?: boolean; showCloseButton?: boolean;
variant?: "default" | "glass";
} = $props(); } = $props();
const variantClasses = {
default: "bg-popover text-popover-foreground ring-1 ring-foreground/10",
glass: "bg-glass-background border-glass-border border backdrop-blur-xl shadow-2xl text-foreground"
};
</script> </script>
<DialogPortal {...portalProps}> <DialogPortal {...portalProps}>
@@ -28,7 +35,8 @@
bind:ref bind:ref
data-slot="dialog-content" data-slot="dialog-content"
class={cn( 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", "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 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm 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",
variantClasses[variant],
className className
)} )}
{...restProps} {...restProps}

View File

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

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("bg-muted animate-pulse rounded-md", className)}
{...restProps}
></div>

View File

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

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
class: className,
checked = $bindable(false),
...restProps
}: SwitchPrimitive.RootProps = $props();
</script>
<SwitchPrimitive.Root
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>

View File

@@ -9,6 +9,18 @@
import { Button } from '$lib/components/ui/button/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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '$lib/components/ui/dialog/index.js';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import {
LayoutDashboard,
Bell,
Settings,
X,
Download,
Info,
AlertTriangle,
RefreshCw,
CheckCircle2
} from '@lucide/svelte';
let { children, pageKey } = $props<{ children: () => unknown; pageKey: string }>(); let { children, pageKey } = $props<{ children: () => unknown; pageKey: string }>();
let recordingDialogOpen = $state(false); let recordingDialogOpen = $state(false);
@@ -51,11 +63,22 @@
<div id="toast-container" class="fixed top-4 right-4 z-50 flex w-[22rem] flex-col gap-3"> <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)} {#each $appState.toasts as toast (toast.id)}
<Alert <Alert
variant={toast.type === 'error' ? 'destructive' : 'default'} variant={toast.type === 'error' ? 'destructive' : 'glass'}
class="glass-card border-white/10 bg-background/95 shadow-lg" class="shadow-lg"
> >
<div class="flex items-start gap-3">
{#if toast.type === 'error'}
<AlertTriangle class="size-4 shrink-0 text-destructive" />
{:else if toast.type === 'success'}
<CheckCircle2 class="size-4 shrink-0 text-emerald-500" />
{:else}
<Info class="size-4 shrink-0 text-blue-500" />
{/if}
<div class="flex-1">
<AlertTitle class="text-xs">{toastTitle(toast.type)}</AlertTitle> <AlertTitle class="text-xs">{toastTitle(toast.type)}</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">{toast.message}</AlertDescription> <AlertDescription class="pr-8 text-xs text-gray-300">{toast.message}</AlertDescription>
</div>
</div>
<AlertAction> <AlertAction>
<Button <Button
variant="ghost" variant="ghost"
@@ -63,9 +86,7 @@
class="text-gray-400 hover:text-white" class="text-gray-400 hover:text-white"
onclick={() => appController.removeToast(toast.id)} 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"> <X class="size-3.5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Dismiss notification</span> <span class="sr-only">Dismiss notification</span>
</Button> </Button>
</AlertAction> </AlertAction>
@@ -73,7 +94,8 @@
{/each} {/each}
{#if $pwaState.installAvailable} {#if $pwaState.installAvailable}
<Alert class="glass-card border-white/10 bg-background/95 shadow-lg"> <Alert variant="glass" class="shadow-lg">
<Download class="size-4 shrink-0 text-blue-500" />
<AlertTitle class="text-xs">Install App</AlertTitle> <AlertTitle class="text-xs">Install App</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300"> <AlertDescription class="pr-8 text-xs text-gray-300">
Add PhoneCam to your home screen for a more native dashboard experience. Add PhoneCam to your home screen for a more native dashboard experience.
@@ -93,7 +115,8 @@
{/if} {/if}
{#if $pwaState.showIosInstallHint} {#if $pwaState.showIosInstallHint}
<Alert class="glass-card border-white/10 bg-background/95 shadow-lg"> <Alert variant="glass" class="shadow-lg">
<Info class="size-4 shrink-0 text-blue-500" />
<AlertTitle class="text-xs">Install on iPhone</AlertTitle> <AlertTitle class="text-xs">Install on iPhone</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300"> <AlertDescription class="pr-8 text-xs text-gray-300">
In Safari, tap the Share button, then choose “Add to Home Screen” to install PhoneCam. In Safari, tap the Share button, then choose “Add to Home Screen” to install PhoneCam.
@@ -108,6 +131,7 @@
{#if $pwaState.updateAvailable} {#if $pwaState.updateAvailable}
<Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg"> <Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg">
<RefreshCw class="size-4 shrink-0 text-blue-500" />
<AlertTitle class="text-xs">Update Ready</AlertTitle> <AlertTitle class="text-xs">Update Ready</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300"> <AlertDescription class="pr-8 text-xs text-gray-300">
A newer version of PhoneCam is ready. Reload to use the latest app shell. A newer version of PhoneCam is ready. Reload to use the latest app shell.
@@ -121,7 +145,8 @@
{/if} {/if}
{#if $pwaState.offlineReady} {#if $pwaState.offlineReady}
<Alert class="glass-card border-emerald-500/20 bg-background/95 shadow-lg"> <Alert variant="glass" class="border-emerald-500/20 shadow-lg">
<CheckCircle2 class="size-4 shrink-0 text-emerald-500" />
<AlertTitle class="text-xs">Offline Shell Ready</AlertTitle> <AlertTitle class="text-xs">Offline Shell Ready</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300"> <AlertDescription class="pr-8 text-xs text-gray-300">
The app shell is now cached for quicker relaunches, but live camera features still need connectivity. The app shell is now cached for quicker relaunches, but live camera features still need connectivity.
@@ -142,20 +167,20 @@
{#if showSidebarSkeleton()} {#if showSidebarSkeleton()}
<div class="p-4 lg:p-6"> <div class="p-4 lg:p-6">
<div class="flex items-center justify-center gap-3 lg:justify-start"> <div class="flex items-center justify-center gap-3 lg:justify-start">
<div class="size-10 rounded-full bg-white/8 animate-pulse"></div> <Skeleton class="size-10 rounded-full" />
<div class="hidden min-w-0 flex-1 flex-col gap-2 lg:flex"> <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> <Skeleton class="h-3 w-28 rounded-full" />
<div class="h-2.5 w-36 rounded-full bg-white/6 animate-pulse"></div> <Skeleton class="h-2.5 w-36 rounded-full" />
</div> </div>
</div> </div>
<div class="mt-3 flex items-center justify-center lg:justify-start"> <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> <Skeleton class="h-6 w-24 rounded-full" />
</div> </div>
</div> </div>
<Separator class="bg-white/5" /> <Separator class="bg-white/5" />
<nav class="flex flex-1 flex-col gap-2 py-6 px-3"> <nav class="flex flex-1 flex-col gap-2 py-6 px-3">
{#each Array(3) as _, index (index)} {#each Array(3) as _, index (index)}
<div class="h-12 w-full rounded-xl bg-white/6 animate-pulse"></div> <Skeleton class="h-12 w-full rounded-xl" />
{/each} {/each}
</nav> </nav>
{:else} {:else}
@@ -187,21 +212,7 @@
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" 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')} onclick={() => appController.navigate('home')}
> >
<svg <LayoutDashboard class="h-5 w-5 shrink-0" />
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> <span class="font-medium hidden lg:block text-sm">Dashboard</span>
</Button> </Button>
@@ -210,21 +221,7 @@
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" 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')} onclick={() => appController.navigate('activity')}
> >
<svg <Bell class="h-5 w-5 shrink-0" />
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> <span class="font-medium hidden lg:block text-sm">Activity Feed</span>
{#if $unreadNotificationsCount > 0} {#if $unreadNotificationsCount > 0}
<Badge <Badge
@@ -246,27 +243,7 @@
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" 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')} onclick={() => appController.navigate('settings')}
> >
<svg <Settings class="h-5 w-5 shrink-0" />
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> <span class="font-medium hidden lg:block text-sm">Settings</span>
</Button> </Button>
</nav> </nav>
@@ -276,8 +253,11 @@
<main class="flex-1 flex flex-col h-full overflow-hidden relative"> <main class="flex-1 flex flex-col h-full overflow-hidden relative">
{#if $pwaState.isOffline} {#if $pwaState.isOffline}
<div class="px-4 pt-4 md:px-8 md:pt-6 lg:px-10"> <div class="px-4 pt-4 md:px-8 md:pt-6 lg:px-10">
<Alert class="glass-card border-amber-500/20 bg-[#141418]/95 shadow-lg"> <Alert variant="glass" class="border-amber-500/20 shadow-lg">
<div class="flex items-center gap-3">
<AlertTriangle class="size-4 text-amber-500" />
<AlertTitle class="text-xs uppercase tracking-wide text-amber-300">{offlineTitle()}</AlertTitle> <AlertTitle class="text-xs uppercase tracking-wide text-amber-300">{offlineTitle()}</AlertTitle>
</div>
<AlertDescription class="text-xs text-gray-300"> <AlertDescription class="text-xs text-gray-300">
{offlineDescription()} {offlineDescription()}
</AlertDescription> </AlertDescription>
@@ -294,7 +274,8 @@
<DialogContent <DialogContent
id="recordingModal" id="recordingModal"
showCloseButton={false} showCloseButton={false}
class="glass-card max-w-5xl border-white/10 bg-[#101014]/95 p-0 shadow-2xl sm:max-w-5xl" variant="glass"
class="max-w-5xl p-0"
> >
<DialogHeader class="gap-3 px-6 pt-6"> <DialogHeader class="gap-3 px-6 pt-6">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
@@ -313,9 +294,7 @@
title="Close recording modal" title="Close recording modal"
onclick={() => appController.closeRecordingModal()} 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"> <X class="size-4" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button> </Button>
</div> </div>
</DialogHeader> </DialogHeader>

View File

@@ -8,23 +8,17 @@
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import { ShieldCheck } from '@lucide/svelte';
</script> </script>
<AppChrome pageKey="auth"> <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"> <section id="screen-auth" class="flex flex-col items-center justify-center min-h-[70vh] animate-fade-in max-w-sm mx-auto">
<Card class="glass-card w-full rounded-3xl border-white/10 bg-[#16161d]/80 shadow-2xl"> <Card variant="glass" class="w-full rounded-3xl">
<CardHeader class="items-center gap-4 px-6 pt-6 text-center"> <CardHeader class="items-center gap-4 px-6 pt-6 text-center">
<div <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" 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"> <ShieldCheck class="size-10 text-white" />
<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>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<CardTitle class="text-3xl font-bold tracking-tight text-white">PhoneCam Web</CardTitle> <CardTitle class="text-3xl font-bold tracking-tight text-white">PhoneCam Web</CardTitle>
@@ -71,7 +65,8 @@
<CardFooter class="flex flex-col items-stretch gap-4 border-0 bg-transparent px-6 pb-6 pt-2"> <CardFooter class="flex flex-col items-stretch gap-4 border-0 bg-transparent px-6 pb-6 pt-2">
<Button <Button
id="signInBtn" id="signInBtn"
class="btn-premium h-12 w-full rounded-xl text-base text-white shadow-lg shadow-blue-900/20" variant="premium"
class="h-12 w-full rounded-xl text-base shadow-lg shadow-blue-900/20"
onclick={() => appController.submitAuth()} onclick={() => appController.submitAuth()}
> >
{$appState.isRegistering ? 'Create Account' : 'Sign In'} {$appState.isRegistering ? 'Create Account' : 'Sign In'}

View File

@@ -7,6 +7,7 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { BellOff, Trash2, Calendar } from '@lucide/svelte';
</script> </script>
<AppChrome pageKey="activity"> <AppChrome pageKey="activity">
@@ -16,14 +17,15 @@
<Button <Button
id="clearActivityBtn" id="clearActivityBtn"
variant="ghost" variant="ghost"
class="rounded-xl border border-transparent text-gray-400 hover:bg-white/5 hover:text-white" class="rounded-xl border border-white/5 text-gray-400 hover:bg-white/5 hover:text-white"
onclick={() => appController.clearNotifications()} onclick={() => appController.clearNotifications()}
> >
<Trash2 class="size-4 mr-2" />
Clear Read Clear Read
</Button> </Button>
</div> </div>
<Card class="glass-card rounded-3xl border-white/10 bg-[#16161d]/80"> <Card variant="glass" class="rounded-3xl">
<CardHeader> <CardHeader>
<CardTitle class="text-sm font-semibold tracking-wide text-white">Motion Alerts</CardTitle> <CardTitle class="text-sm font-semibold tracking-wide text-white">Motion Alerts</CardTitle>
</CardHeader> </CardHeader>
@@ -31,17 +33,10 @@
<ScrollArea class="max-h-[32rem] pr-3"> <ScrollArea class="max-h-[32rem] pr-3">
<div id="activityFeedList" class="flex flex-col gap-3"> <div id="activityFeedList" class="flex flex-col gap-3">
{#if $appState.motionNotifications.length === 0} {#if $appState.motionNotifications.length === 0}
<Alert class="border-white/10 bg-black/20 text-center opacity-70"> <Alert variant="default" class="border-white/10 bg-black/20 text-center opacity-70 py-10">
<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"> <BellOff class="h-12 w-12 mx-auto mb-4 text-gray-600" />
<path <AlertTitle class="text-sm text-gray-200">All quiet</AlertTitle>
stroke-linecap="round" <AlertDescription class="text-sm text-gray-400">
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>
<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. No notifications yet.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -50,11 +45,14 @@
<Button <Button
type="button" type="button"
variant="ghost" 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'}" class="h-auto w-full flex-col items-start gap-1 rounded-xl border border-white/5 px-4 py-3 text-left transition-all hover:bg-white/5 {notification.isRead ? 'bg-gray-900/30 text-gray-400' : 'bg-blue-600/10 text-white border-blue-500/20'}"
onclick={() => appController.openMotionNotificationTarget(notification.id, notification.cameraDeviceId)} onclick={() => appController.openMotionNotificationTarget(notification.id, notification.cameraDeviceId)}
> >
<p class="text-xs font-medium">{notification.message}</p> <p class="text-sm font-semibold">{notification.message}</p>
<div class="flex items-center gap-2 mt-1">
<Calendar class="size-3 text-gray-500" />
<p class="text-[10px] text-gray-500">{new Date(notification.createdAt).toLocaleString()}</p> <p class="text-[10px] text-gray-500">{new Date(notification.createdAt).toLocaleString()}</p>
</div>
</Button> </Button>
{/each} {/each}
{/if} {/if}

View File

@@ -10,6 +10,17 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select/index.js'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import {
VideoOff,
RefreshCw,
Zap,
Square,
Activity,
Settings2,
Terminal,
Camera
} from '@lucide/svelte';
let cameraVideoElement: HTMLVideoElement | null = null; let cameraVideoElement: HTMLVideoElement | null = null;
@@ -40,7 +51,7 @@
<AppChrome pageKey="camera"> <AppChrome pageKey="camera">
<section id="screen-home-camera" class="mx-auto flex h-full min-h-0 max-w-7xl flex-col gap-4 lg:gap-6"> <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"> <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]"> <Card variant="glass" class="relative flex min-h-[220px] overflow-hidden 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' : ''}"> <div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center {$appState.isMotionActive ? 'bg-red-900/20' : ''}">
<video <video
id="cameraVideo" id="cameraVideo"
@@ -52,10 +63,10 @@
></video> ></video>
<Badge <Badge
variant="destructive" variant="destructive"
class="absolute top-4 left-4 z-20 gap-2 rounded-full border border-white/10 bg-black/50 px-3 py-1.5 text-white backdrop-blur" class="absolute top-4 left-4 z-20 gap-2 rounded-full border border-white/10 bg-black/50 px-3 py-1.5 backdrop-blur"
> >
<span class="w-2.5 h-2.5 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span> <span class="w-2 h-2 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span>
<span class="text-xs font-medium tracking-wide">REC</span> <span class="text-[10px] font-bold tracking-widest">REC</span>
</Badge> </Badge>
</div> </div>
@@ -63,24 +74,17 @@
id="cameraOfflineOverlay" id="cameraOfflineOverlay"
class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4 {$appState.socketConnected ? 'hidden' : ''}" class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4 {$appState.socketConnected ? 'hidden' : ''}"
> >
<Alert class="w-full max-w-sm border-white/10 bg-[#141418]/90 text-center shadow-xl"> <Alert variant="default" class="w-full max-w-sm border-white/10 bg-[#141418]/90 text-center shadow-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-16 w-16 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <VideoOff class="mx-auto h-12 w-12 text-gray-600 mb-2" />
<path <AlertTitle class="text-base text-white">Camera Offline</AlertTitle>
stroke-linecap="round" <AlertDescription class="text-sm text-gray-400">
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<AlertTitle class="justify-self-center text-base text-white">Camera Offline</AlertTitle>
<AlertDescription class="justify-self-center text-sm text-gray-400">
Reconnect this device to resume live monitoring. Reconnect this device to resume live monitoring.
</AlertDescription> </AlertDescription>
<div class="mt-2 flex justify-center"> <div class="mt-4 flex justify-center">
<Button <Button
id="cameraGoOnlineBtn" id="cameraGoOnlineBtn"
variant="outline" variant="outline"
class="rounded-xl border-green-500/50 text-green-400 hover:border-green-400 hover:bg-green-500/10" class="rounded-xl border-emerald-500/50 text-emerald-400 hover:border-emerald-400 hover:bg-emerald-500/10"
onclick={() => appController.goOnline()} onclick={() => appController.goOnline()}
> >
Go Online Go Online
@@ -91,9 +95,10 @@
</Card> </Card>
<div class="grid grid-cols-1 gap-4 lg:gap-6 xl:min-h-0 xl:flex-[2] xl:grid-cols-[2fr_1fr]"> <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"> <Card variant="glass" class="min-h-[220px] overflow-hidden xl:min-h-0">
<CardHeader class="pb-2"> <CardHeader class="pb-2 flex flex-row items-center gap-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Logs</CardTitle> <Terminal class="size-3.5 text-gray-500" />
<CardTitle class="text-[10px] font-bold uppercase tracking-widest text-gray-500">Activity Logs</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="flex-1 xl:min-h-0"> <CardContent class="flex-1 xl:min-h-0">
<ScrollArea <ScrollArea
@@ -104,30 +109,37 @@
<div class="text-gray-600 italic">Awaiting connection...</div> <div class="text-gray-600 italic">Awaiting connection...</div>
{:else} {:else}
{#each $appState.activityLog as log (log.id)} {#each $appState.activityLog as log (log.id)}
<div>[{new Date(log.createdAt).toLocaleTimeString()}] {log.type}: {log.message}</div> <div class="mb-1 leading-relaxed">
<span class="text-gray-600">[{new Date(log.createdAt).toLocaleTimeString()}]</span>
<span class="text-blue-400/80">{log.type}:</span>
<span>{log.message}</span>
</div>
{/each} {/each}
{/if} {/if}
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
</Card> </Card>
<Card class="glass-card min-h-[220px] overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 xl:min-h-0"> <Card variant="glass" class="min-h-[220px] overflow-hidden xl:min-h-0">
<CardHeader class="pb-2"> <CardHeader class="pb-2 flex flex-row items-center gap-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-500">Actions / Options</CardTitle> <Settings2 class="size-3.5 text-gray-500" />
<CardTitle class="text-[10px] font-bold uppercase tracking-widest text-gray-500">Control Plane</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-4 xl:min-h-0 xl:overflow-y-auto"> <CardContent class="space-y-4 xl:min-h-0 xl:overflow-y-auto">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label for="cameraInputSelect" class="text-[11px] font-semibold uppercase tracking-wider text-gray-400"> <Label for="cameraInputSelect" class="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
<Camera class="size-3" />
Camera Input Camera Input
</Label> </Label>
<Button <Button
id="refreshCameraInputsBtn" id="refreshCameraInputsBtn"
variant="ghost" variant="ghost"
size="xs" size="xs"
class="rounded-lg text-gray-400 hover:text-white" class="h-6 rounded-lg text-gray-500 hover:text-white"
onclick={() => appController.refreshCameraInputs()} onclick={() => appController.refreshCameraInputs()}
> >
<RefreshCw class="size-3 mr-1" />
Refresh Refresh
</Button> </Button>
</div> </div>
@@ -150,20 +162,21 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<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="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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">Automatic Detection</p> <p class="text-[10px] font-bold uppercase tracking-widest text-gray-400">Automatic Detection</p>
<p class="text-sm text-gray-200"> <p class="text-sm text-gray-200 font-medium">
{$appState.motionDetection.enabled ? 'Armed for automatic motion events.' : 'Paused until manually armed.'} {$appState.motionDetection.enabled ? 'Armed' : 'Disarmed'}
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-[10px] text-gray-500 leading-tight">
Designed for a plugged-in browser kept in the foreground. Foreground browser monitoring.
</p> </p>
</div> </div>
<Button <Button
variant={$appState.motionDetection.enabled ? 'secondary' : 'outline'} variant={$appState.motionDetection.enabled ? 'secondary' : 'outline'}
class="min-w-[112px] self-start rounded-xl sm:self-auto" class="min-w-[80px] h-9 self-start rounded-xl sm:self-auto text-xs"
onclick={() => appController.setMotionDetectionEnabled(!$appState.motionDetection.enabled)} onclick={() => appController.setMotionDetectionEnabled(!$appState.motionDetection.enabled)}
> >
{$appState.motionDetection.enabled ? 'Pause' : 'Arm'} {$appState.motionDetection.enabled ? 'Pause' : 'Arm'}
@@ -172,10 +185,10 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label for="motionDetectionProfile" class="text-[11px] font-semibold uppercase tracking-wider text-gray-400"> <Label for="motionDetectionProfile" class="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Detection Profile Profile
</Label> </Label>
<span class="text-[11px] uppercase tracking-wider text-gray-500">{$appState.motionDetection.state}</span> <span class="text-[10px] font-bold uppercase tracking-widest text-blue-500/80">{$appState.motionDetection.state}</span>
</div> </div>
<Select <Select
type="single" type="single"
@@ -197,61 +210,48 @@
</Select> </Select>
</div> </div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="grid grid-cols-2 gap-2">
<div class="rounded-xl border border-white/5 bg-white/5 px-3 py-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="text-[9px] font-bold uppercase tracking-widest text-gray-500">Detector</p>
<p class="mt-1 text-sm font-medium text-gray-200">{$appState.motionDetection.state}</p> <p class="mt-0.5 text-xs font-bold text-gray-200">{$appState.motionDetection.state}</p>
</div> </div>
<div class="rounded-xl border border-white/5 bg-white/5 px-3 py-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">Motion Score</p> <p class="text-[9px] font-bold uppercase tracking-widest text-gray-500">Score</p>
<p class="mt-1 text-sm font-medium text-gray-200">{$appState.motionDetection.score.toFixed(3)}</p> <p class="mt-0.5 text-xs font-bold text-gray-200">{$appState.motionDetection.score.toFixed(3)}</p>
</div> </div>
</div> </div>
<div class="flex items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/5 px-3 py-2"> <div class="flex items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/5 px-3 py-2">
<div> <div class="space-y-0.5">
<p class="text-xs font-medium text-gray-200">Show detector debug info</p> <p class="text-xs font-bold text-gray-200">Debug Overlay</p>
<p class="text-[11px] text-gray-500">Useful while tuning thresholds and sample profiles.</p> <p class="text-[10px] text-gray-500">Sample profiles & metrics.</p>
</div> </div>
<button <Switch
type="button" checked={$appState.motionDetection.debug}
role="switch" onCheckedChange={(v) => appController.setMotionDetectionDebug(v)}
aria-checked={$appState.motionDetection.debug} />
aria-label="Toggle motion detector debug info"
title="Toggle motion detector debug info"
class="inline-flex h-7 w-12 items-center rounded-full border border-white/10 p-1 transition-colors {$appState.motionDetection.debug ? 'bg-blue-500/80' : 'bg-white/10'}"
onclick={() => appController.setMotionDetectionDebug(!$appState.motionDetection.debug)}
>
<span
class="h-5 w-5 rounded-full bg-white transition-transform {$appState.motionDetection.debug ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div> </div>
</div> </div>
<div class="space-y-3">
<div class="space-y-2">
{#if !$appState.isMotionActive} {#if !$appState.isMotionActive}
<Button <Button
id="startMotionBtn" id="startMotionBtn"
variant="outline" variant="outline"
class="h-14 w-full justify-start rounded-xl border-white/5 bg-white/5 font-medium hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400" class="h-12 w-full justify-start rounded-xl border-white/5 bg-white/5 font-bold text-sm hover:border-blue-500/30 hover:bg-blue-500/10 hover:text-blue-400 group"
onclick={() => appController.startMotion()} onclick={() => appController.startMotion()}
> >
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="mr-2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Zap class="mr-3 h-4 w-4 text-gray-400 group-hover:text-blue-400" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> Simulate Motion
</svg>
Simulate Motion Event
</Button> </Button>
{:else} {:else}
<Button <Button
id="endMotionBtn" id="endMotionBtn"
variant="outline" variant="outline"
class="h-14 w-full justify-start rounded-xl border-white/5 bg-white/5 font-medium hover:bg-white/10 disabled:opacity-30" class="h-12 w-full justify-start rounded-xl border-white/5 bg-white/5 font-bold text-sm hover:bg-white/10"
onclick={() => appController.endMotion()} onclick={() => appController.endMotion()}
> >
<svg data-icon="inline-start" xmlns="http://www.w3.org/2000/svg" class="mr-2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Square class="mr-3 h-4 w-4 text-red-500 animate-pulse" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Stop Recording Stop Recording
</Button> </Button>
{/if} {/if}

View File

@@ -9,6 +9,20 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '$lib/components/ui/dropdown-menu/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Plus,
RefreshCw,
Monitor,
Eye,
Video,
VideoOff,
MoreVertical,
X,
History,
Activity,
ShieldCheck,
Clock
} from '@lucide/svelte';
let clientVideoElement: HTMLVideoElement | null = null; let clientVideoElement: HTMLVideoElement | null = null;
@@ -56,62 +70,55 @@
</script> </script>
<AppChrome pageKey="client"> <AppChrome pageKey="client">
<section id="screen-home-client" class="mx-auto flex h-full max-w-7xl min-h-0 flex-col gap-8"> <section id="screen-home-client" class="mx-auto flex h-full max-w-7xl min-h-0 flex-col gap-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center px-1">
<h2 class="text-2xl font-bold text-white tracking-tight">Client Dashboard</h2> <h2 class="text-2xl font-bold text-white tracking-tight">Client Dashboard</h2>
<div class="flex gap-3"> <div class="flex gap-2">
<Button <Button
id="linkCameraBtn" id="linkCameraBtn"
variant="outline" variant="outline"
size="sm"
class="rounded-xl border-white/10 text-gray-300 shadow-none hover:bg-white/5 hover:text-white" class="rounded-xl border-white/10 text-gray-300 shadow-none hover:bg-white/5 hover:text-white"
onclick={() => appController.linkCamera()} onclick={() => appController.linkCamera()}
> >
<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"> <Plus class="h-4 w-4 mr-1.5" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Link Camera Link Camera
</Button> </Button>
<Button <Button
id="refreshClientBtn" id="refreshClientBtn"
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
class="rounded-xl border border-white/5 bg-white/5 hover:bg-white/10" class="rounded-xl border border-white/5 bg-white/5 hover:bg-white/10"
aria-label="Refresh linked cameras" aria-label="Refresh linked cameras"
title="Refresh linked cameras" title="Refresh linked cameras"
onclick={() => appController.refreshClientData()} onclick={() => appController.refreshClientData()}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <RefreshCw class="h-4 w-4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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> </div>
<Card class="glass-card mb-4 shrink-0 rounded-3xl border-white/10 bg-[#16161d]/80"> <Card variant="glass" class="mb-2 shrink-0 rounded-3xl">
<CardHeader> <CardHeader class="pb-3 flex flex-row items-center gap-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-400">Your Cameras</CardTitle> <Monitor class="size-3.5 text-gray-500" />
<CardTitle class="text-[10px] font-bold uppercase tracking-widest text-gray-500">Your Cameras</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{#if $appState.linkedCameras.length === 0} {#if $appState.linkedCameras.length === 0}
<Alert class="border-dashed border-white/10 bg-black/20 text-center"> <Alert variant="default" class="border-dashed border-white/10 bg-black/20 text-center py-8">
<AlertTitle class="justify-self-center text-sm text-gray-200">No cameras linked</AlertTitle> <AlertTitle class="text-sm text-gray-200">No cameras linked</AlertTitle>
<AlertDescription class="justify-self-center text-sm text-gray-500"> <AlertDescription class="text-xs text-gray-500">
Link a camera to start viewing live feeds and recordings. Link a camera to start viewing live feeds and recordings.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{:else} {:else}
<ScrollArea class="w-full whitespace-nowrap" orientation="horizontal"> <ScrollArea class="w-full whitespace-nowrap" orientation="horizontal">
<div id="linkedCamerasList" class="flex gap-4 pb-2"> <div id="linkedCamerasList" class="flex gap-4 pb-3">
{#each $appState.linkedCameras as link (link.id)} {#each $appState.linkedCameras as link (link.id)}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
class="min-w-[240px] max-w-[240px] flex-shrink-0 cursor-pointer text-left" class="min-w-[240px] max-w-[240px] flex-shrink-0 cursor-pointer text-left group"
onclick={(event) => { onclick={(event) => {
if ((event.target as HTMLElement).closest('[data-menu-trigger]')) return; if ((event.target as HTMLElement).closest('[data-menu-trigger]')) return;
appController.selectCamera(link.cameraDeviceId); appController.selectCamera(link.cameraDeviceId);
@@ -122,25 +129,20 @@
> >
<Card <Card
size="sm" 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' }" class="overflow-hidden rounded-xl border transition-all duration-300 { $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"> <div class="relative aspect-video overflow-hidden border-b border-white/5 bg-black/40">
{#if $appState.activeCameraDeviceId === link.cameraDeviceId} {#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"> <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"> <Eye class="h-6 w-6" />
<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"> <Badge variant="secondary" class="rounded-full px-2 text-[10px] uppercase tracking-wider">
Viewing Viewing
</Badge> </Badge>
</div> </div>
{:else} {:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500 transition-colors hover:text-gray-300"> <div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 transition-colors group-hover:text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Video class="h-6 w-6" />
<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" /> <p class="text-[10px] font-medium tracking-wide">{isCameraLive(link.cameraDeviceId) ? 'LIVE' : 'CLICK TO VIEW'}</p>
</svg>
<p class="text-[10px]">{isCameraLive(link.cameraDeviceId) ? 'Live Stream Active' : 'Click to view'}</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -152,9 +154,9 @@
</p> </p>
<Badge <Badge
variant={link.cameraStatus?.toLowerCase() === 'online' ? 'secondary' : 'outline'} variant={link.cameraStatus?.toLowerCase() === 'online' ? 'secondary' : 'outline'}
class="gap-1.5 rounded-full px-2 capitalize" class="gap-1.5 rounded-full px-2 capitalize text-[10px]"
> >
<span class="h-2 w-2 rounded-full shrink-0 {statusDotClass(link.cameraStatus)}"></span> <span class="h-1.5 w-1.5 rounded-full shrink-0 {statusDotClass(link.cameraStatus)} shadow-[0_0_4px_rgba(16,185,129,0.4)]"></span>
{link.cameraStatus || 'offline'} {link.cameraStatus || 'offline'}
</Badge> </Badge>
</div> </div>
@@ -168,11 +170,7 @@
class="text-gray-400 hover:text-white" class="text-gray-400 hover:text-white"
{...props} {...props}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <MoreVertical class="h-3.5 w-3.5" />
<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> </Button>
{/snippet} {/snippet}
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -207,31 +205,32 @@
</CardContent> </CardContent>
</Card> </Card>
<div class="flex flex-1 min-h-0 flex-col gap-8 xl:flex-row"> <div class="flex flex-1 min-h-0 flex-col gap-6 xl:flex-row">
<Card <Card
id="clientStreamViewerWrapper" id="clientStreamViewerWrapper"
class="glass-card min-w-0 flex-1 overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80 shadow-xl {$appState.activeCameraDeviceId ? 'flex min-h-[400px] flex-col xl:min-h-0' : 'hidden'}" variant="glass"
class="min-w-0 flex-1 overflow-hidden shadow-xl {$appState.activeCameraDeviceId ? 'flex min-h-[400px] flex-col xl:min-h-0' : 'hidden'}"
> >
<CardHeader class="border-b border-white/5 bg-black/20 px-5 py-4"> <CardHeader class="border-b border-white/5 bg-black/20 px-5 py-3">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center 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> {#if isCameraLive($appState.activeCameraDeviceId)}
<CardTitle class="font-medium tracking-wide text-white" id="clientStreamViewerTitle"> <span class="w-2 h-2 rounded-full bg-red-500 animate-[pulse_2s_ease-in-out_infinite]" id="clientLiveDot"></span>
Live Feed: {activeCameraLabel()} {/if}
<CardTitle class="text-sm font-semibold tracking-wide text-white" id="clientStreamViewerTitle">
{activeCameraLabel()}
</CardTitle> </CardTitle>
</div> </div>
<Button <Button
id="closeStreamViewerBtn" id="closeStreamViewerBtn"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-xs"
class="text-gray-400 hover:text-white" class="text-gray-400 hover:text-white"
aria-label="Close stream viewer" aria-label="Close stream viewer"
title="Close stream viewer" title="Close stream viewer"
onclick={() => appController.closeStreamViewer()} 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"> <X class="h-4 w-4" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -246,44 +245,40 @@
bind:this={clientVideoElement} bind:this={clientVideoElement}
></video> ></video>
<Alert <div
id="clientStreamPlaceholder" id="clientStreamPlaceholder"
class="relative z-10 m-6 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'}" class="relative z-10 m-6 flex max-w-sm flex-col items-center gap-4 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"> <div class="p-5 rounded-full bg-white/5 mb-2">
<path <VideoOff class="h-10 w-10 text-gray-600" />
stroke-linecap="round" </div>
stroke-linejoin="round" <p class="text-base font-semibold text-white">Stream unavailable</p>
stroke-width="2" <p class="text-xs font-bold uppercase tracking-widest text-gray-500">
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>
<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} {$appState.clientPlaceholderText}
</AlertDescription> </p>
</Alert> </div>
</CardContent> </CardContent>
{#if activeStreamDiagnostics()} {#if activeStreamDiagnostics()}
<div class="border-t border-white/5 bg-black/20 px-5 py-4"> <div class="border-t border-white/5 bg-black/20 px-5 py-4">
<div class="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-gray-500"> <div class="flex flex-wrap items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
<span>Live Diagnostics</span> <Activity class="size-3 text-blue-500" />
<Badge variant="outline" class="border-white/10 text-[10px] text-gray-400"> <span>Diagnostics</span>
<Badge variant="outline" class="border-white/5 text-[9px] text-gray-500 px-1.5 font-mono">
{activeStreamSessionId()?.slice(0, 8)} {activeStreamSessionId()?.slice(0, 8)}
</Badge> </Badge>
{#if isCameraLive($appState.activeCameraDeviceId)} {#if isCameraLive($appState.activeCameraDeviceId)}
<Badge variant="secondary" class="rounded-full px-2 text-[10px] uppercase tracking-wide">Peer connected</Badge> <Badge variant="secondary" class="rounded-full px-2 text-[9px] font-bold uppercase tracking-widest">Peer connected</Badge>
{/if} {/if}
</div> </div>
<div class="mt-3 grid gap-2"> <div class="mt-4 grid gap-2">
{#each activeStreamDiagnostics().entries.slice(0, 6) as entry (entry.id)} {#each activeStreamDiagnostics().entries.slice(0, 4) as entry (entry.id)}
<div class="rounded-xl border px-3 py-2 {diagnosticLevelClass(entry.level)}"> <div class="rounded-xl border px-3 py-2 {diagnosticLevelClass(entry.level)} transition-colors">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em]">{entry.stage}</span> <span class="text-[10px] font-bold uppercase tracking-wider">{entry.stage}</span>
<span class="text-[10px] text-gray-500">{new Date(entry.createdAt).toLocaleTimeString()}</span> <span class="text-[9px] font-medium opacity-50">{new Date(entry.createdAt).toLocaleTimeString()}</span>
</div> </div>
<p class="mt-1 text-sm text-current">{entry.message}</p> <p class="mt-1 text-xs text-current opacity-90">{entry.message}</p>
</div> </div>
{/each} {/each}
</div> </div>
@@ -292,34 +287,38 @@
</Card> </Card>
<div class="flex shrink-0 flex-col gap-6 pr-2 xl:max-h-full xl:w-96 xl:overflow-y-auto"> <div class="flex shrink-0 flex-col gap-6 pr-2 xl:max-h-full xl:w-96 xl:overflow-y-auto">
<Card class="glass-card flex-1 rounded-3xl border-white/10 bg-[#16161d]/80"> <Card variant="glass" class="flex-1 rounded-3xl">
<CardHeader> <CardHeader class="pb-3 flex flex-row items-center gap-2">
<CardTitle class="text-xs font-bold uppercase tracking-wider text-gray-400">Recent Recordings</CardTitle> <History class="size-3.5 text-gray-500" />
<CardTitle class="text-[10px] font-bold uppercase tracking-widest text-gray-400">Recent Clips</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea class="max-h-[26rem] pr-3"> <ScrollArea class="max-h-[30rem] pr-3">
<div id="recordingsList" class="space-y-3"> <div id="recordingsList" class="space-y-3">
{#if $appState.recordings.length === 0} {#if $appState.recordings.length === 0}
<Alert class="border-white/10 bg-gray-900/30 text-center"> <Alert variant="default" class="border-dashed border-white/10 bg-gray-900/30 text-center py-6">
<AlertTitle class="justify-self-center text-xs text-gray-200">No recordings found</AlertTitle> <AlertTitle class="text-xs text-gray-200">No recordings found</AlertTitle>
<AlertDescription class="justify-self-center text-xs text-gray-500"> <AlertDescription class="text-[10px] text-gray-500">
Captured clips will appear here when they are ready. Captured clips will appear here.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{:else} {:else}
{#each $appState.recordings.slice(0, 5) as recording (recording.id)} {#each $appState.recordings.slice(0, 8) as recording (recording.id)}
<Card size="sm" class="border-white/5 bg-gray-900/40"> <Card size="sm" class="border-white/5 bg-white/5 hover:bg-white/10 transition-colors">
<CardContent class="flex items-center justify-between gap-4 px-3 py-3"> <CardContent class="flex items-center justify-between gap-4 px-3 py-3">
<div class="flex flex-col"> <div class="flex flex-col gap-1">
<span class="text-xs font-medium text-gray-300">{new Date(recording.createdAt).toLocaleString()}</span> <div class="flex items-center gap-1.5">
<span class="text-[10px] text-gray-500"> <Clock class="size-3 text-gray-500" />
{recording.durationSeconds != null ? `${recording.durationSeconds}s duration` : 'Duration pending'} · {recording.status ?? 'unknown'} <span class="text-xs font-semibold text-gray-300">{new Date(recording.createdAt).toLocaleTimeString()}</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-widest text-gray-500">
{recording.status ?? 'unknown'} · {recording.durationSeconds != null ? `${recording.durationSeconds}s` : '--'}
</span> </span>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="xs" size="xs"
class="border-white/10 text-gray-400" class="h-7 rounded-lg border-white/10 text-xs font-bold text-gray-400 hover:text-white"
disabled={recording.status !== 'ready'} disabled={recording.status !== 'ready'}
onclick={() => appController.openRecording(recording.id)} onclick={() => appController.openRecording(recording.id)}
> >

View File

@@ -8,13 +8,14 @@
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group/index.js'; import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group/index.js';
import { Settings } from '@lucide/svelte';
let showAdvancedSettings = false; let showAdvancedSettings = false;
</script> </script>
<AppChrome pageKey="onboarding"> <AppChrome pageKey="onboarding">
<section id="screen-onboarding" class="flex flex-col items-center justify-center min-h-[70vh] max-w-lg mx-auto"> <section id="screen-onboarding" class="flex flex-col items-center justify-center min-h-[70vh] max-w-lg mx-auto">
<Card class="glass-card w-full rounded-3xl border-white/10 bg-[#16161d]/80 shadow-2xl"> <Card variant="glass" class="w-full rounded-3xl">
<CardHeader class="px-6 pt-6 text-center"> <CardHeader class="px-6 pt-6 text-center">
<CardTitle class="text-3xl font-bold tracking-tight text-white">Configure Device</CardTitle> <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> <CardDescription class="text-sm text-gray-400">Set up this browser dashboard role</CardDescription>
@@ -75,15 +76,7 @@
aria-expanded={showAdvancedSettings} aria-expanded={showAdvancedSettings}
onclick={() => (showAdvancedSettings = !showAdvancedSettings)} onclick={() => (showAdvancedSettings = !showAdvancedSettings)}
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Settings class="h-5 w-5" />
<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>
</Button> </Button>
</div> </div>
@@ -107,7 +100,8 @@
<CardFooter class="flex flex-col items-stretch gap-3 border-0 bg-transparent px-6 pb-6 pt-2"> <CardFooter class="flex flex-col items-stretch gap-3 border-0 bg-transparent px-6 pb-6 pt-2">
<Button <Button
id="registerBtn" id="registerBtn"
class="btn-premium h-12 w-full rounded-xl text-base text-white" variant="premium"
class="h-12 w-full rounded-xl text-base"
onclick={() => appController.registerDevice()} onclick={() => appController.registerDevice()}
> >
Complete Setup Complete Setup

View File

@@ -8,6 +8,15 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import {
BarChart3,
ChevronRight,
Settings,
LogOut,
Database,
ShieldCheck,
Activity
} from '@lucide/svelte';
const profileName = () => $appState.session?.user?.name || 'User'; const profileName = () => $appState.session?.user?.name || 'User';
const profileEmail = () => $appState.session?.user?.email || 'user@example.com'; const profileEmail = () => $appState.session?.user?.email || 'user@example.com';
@@ -58,8 +67,8 @@
<section id="screen-settings" class="flex flex-col gap-6 max-w-2xl mx-auto py-8"> <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> <h2 class="text-2xl font-bold text-white tracking-tight px-2">Settings</h2>
<Card class="glass-card rounded-3xl border-white/10 bg-[#16161d]/80"> <Card variant="glass" class="rounded-3xl">
<CardContent class="flex items-center gap-6"> <CardContent class="flex items-center gap-6 py-6">
<Avatar id="profileInitials" size="lg" class="size-20 bg-blue-600/20 text-2xl font-bold text-blue-500"> <Avatar id="profileInitials" size="lg" class="size-20 bg-blue-600/20 text-2xl font-bold text-blue-500">
<AvatarFallback>{profileInitial()}</AvatarFallback> <AvatarFallback>{profileInitial()}</AvatarFallback>
</Avatar> </Avatar>
@@ -70,9 +79,9 @@
</CardContent> </CardContent>
</Card> </Card>
<Card class="glass-card overflow-hidden rounded-3xl border-white/10 bg-[#16161d]/80"> <Card variant="glass" class="overflow-hidden rounded-3xl">
<CardHeader> <CardHeader>
<CardTitle class="text-sm font-semibold tracking-wide text-white">Tools</CardTitle> <CardTitle class="text-sm font-semibold tracking-wide text-white">Tools & Diagnostics</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="flex flex-col gap-0 p-0"> <CardContent class="flex flex-col gap-0 p-0">
<Button <Button
@@ -83,39 +92,34 @@
> >
<div class="flex items-center gap-4"> <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"> <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"> <BarChart3 class="h-5 w-5" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div> </div>
<span class="font-medium">Run Diagnostics</span> <span class="font-medium">Run System Diagnostics</span>
</div> </div>
<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"> <ChevronRight class="h-4 w-4 text-gray-600 group-hover:text-gray-400 transition-colors" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Button> </Button>
<Separator class="bg-white/5" /> <Separator class="bg-white/5" />
<div class="px-5 py-5"> <div class="px-5 py-5">
<div class="flex flex-col gap-4 rounded-2xl border border-white/8 bg-white/[0.03] p-4"> <div class="flex flex-col gap-4 rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2">
<Database class="size-4 text-blue-500" />
<h4 class="text-sm font-semibold tracking-wide text-white">MinIO Storage Check</h4> <h4 class="text-sm font-semibold tracking-wide text-white">MinIO Storage Check</h4>
<p class="text-sm text-gray-400"> </div>
Test whether the backend can still reach the object storage server. <p class="text-xs text-gray-400">
Test backend connectivity to object storage.
</p> </p>
</div> </div>
<Button <Button
id="checkMinioBtn" id="checkMinioBtn"
size="sm" size="sm"
class="shrink-0 rounded-xl" variant="outline"
class="shrink-0 rounded-xl border-white/10"
disabled={isCheckingMinio} disabled={isCheckingMinio}
onclick={checkMinioServer} onclick={checkMinioServer}
> >
{isCheckingMinio ? 'Checking…' : 'Check MinIO Server'} {isCheckingMinio ? 'Checking…' : 'Check Now'}
</Button> </Button>
</div> </div>
@@ -127,12 +131,19 @@
: 'border-red-500/30 bg-red-500/10 text-red-300' : 'border-red-500/30 bg-red-500/10 text-red-300'
}`} }`}
> >
<div class="flex items-center gap-2">
{#if minioStatus === 'ok'}
<ShieldCheck class="size-4" />
{:else}
<Activity class="size-4" />
{/if}
<p class="font-medium"> <p class="font-medium">
{minioStatus === 'ok' ? 'MinIO is up' : 'MinIO check failed'} {minioStatus === 'ok' ? 'MinIO is up' : 'MinIO check failed'}
</p> </p>
<p class="mt-1 opacity-90">{minioStatusMessage}</p> </div>
<p class="mt-1 opacity-90 text-xs">{minioStatusMessage}</p>
{#if minioCheckedAt} {#if minioCheckedAt}
<p class="mt-2 text-xs opacity-75">Last checked: {formatCheckedAt(minioCheckedAt)}</p> <p class="mt-2 text-[10px] opacity-75 italic">Last checked: {formatCheckedAt(minioCheckedAt)}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -142,21 +153,11 @@
<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"> <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="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"> <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"> <Settings class="h-5 w-5" />
<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>
</div> </div>
<span class="font-medium">Device Information</span> <span class="font-medium">Device Information</span>
</div> </div>
<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"> <ChevronRight class="h-4 w-4 text-gray-600 group-hover:text-gray-400 transition-colors" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -164,17 +165,10 @@
<Button <Button
id="signOutBtn" id="signOutBtn"
variant="destructive" 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" class="mt-4 h-14 w-full rounded-2xl border border-red-500/50 text-base font-bold hover:border-red-500 hover:bg-red-500/10"
onclick={() => appController.signOut()} onclick={() => appController.signOut()}
> >
<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"> <LogOut class="mr-3 h-5 w-5" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
Sign Out Sign Out
</Button> </Button>
</section> </section>