-
-
-
+
+
+
+
+
-
-
{children}
+
{children}
-)
+);
diff --git a/components/command-menu.tsx b/components/command-menu.tsx
index 434f487..0159373 100644
--- a/components/command-menu.tsx
+++ b/components/command-menu.tsx
@@ -1,17 +1,6 @@
"use client";
-import {
- IconBug,
- IconChartBar,
- IconDatabase,
- IconEye,
- IconFileText,
- IconList,
- IconMessageChatbot,
- IconPlayerPlay,
- IconSearch,
- IconVideo,
-} from "@tabler/icons-react";
+import { IconRefresh, IconRocket } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import * as React from "react";
@@ -24,69 +13,8 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
-
-const navigationItems = [
- {
- group: "Stream",
- items: [
- {
- title: "Create Stream",
- url: "/stream",
- icon: IconVideo,
- },
- ],
- },
- {
- group: "Dashboard",
- items: [
- {
- title: "Watch",
- url: "/watch",
- icon: IconPlayerPlay,
- },
- {
- title: "Stats",
- url: "/stats",
- icon: IconChartBar,
- },
- {
- title: "Search",
- url: "/search",
- icon: IconSearch,
- },
- {
- title: "AI Chat",
- url: "/ai-chat",
- icon: IconMessageChatbot,
- },
- {
- title: "Reports",
- url: "/reports",
- icon: IconFileText,
- },
- ],
- },
- {
- group: "Admin",
- items: [
- {
- title: "Jobs",
- url: "/jobs",
- icon: IconList,
- },
- {
- title: "Database",
- url: "/database",
- icon: IconDatabase,
- },
- {
- title: "Debug",
- url: "/debug",
- icon: IconBug,
- },
- ],
- },
-];
+import { isDemoMode } from "@/lib/demo/flag";
+import { navSections, resetDemoAndReload } from "@/lib/navigation";
export function CommandMenu() {
const [open, setOpen] = React.useState(false);
@@ -109,15 +37,20 @@ export function CommandMenu() {
router.push(url);
};
+ const handleResetDemo = () => {
+ setOpen(false);
+ resetDemoAndReload();
+ };
+
return (
No results found.
- {navigationItems.map((section, idx) => (
-
+ {navSections.map((section, idx) => (
+
{idx > 0 && }
-
+
{section.items.map((item) => {
const Icon = item.icon;
return (
@@ -134,6 +67,27 @@ export function CommandMenu() {
))}
+ {isDemoMode && (
+ <>
+
+
+ handleSelect("/onboarding")}
+ className="cursor-pointer"
+ >
+
+ Run setup copilot
+
+
+
+ Reset demo
+
+
+ >
+ )}
);
diff --git a/components/cta-link.tsx b/components/cta-link.tsx
new file mode 100644
index 0000000..a18c7b2
--- /dev/null
+++ b/components/cta-link.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import Link from "next/link";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+const baseClasses =
+ "inline-flex items-center gap-2 rounded-md bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity";
+
+const disabledClasses = "opacity-50 cursor-not-allowed pointer-events-none";
+
+type CtaLinkAsLink = {
+ href: string;
+ onClick?: never;
+ type?: never;
+} & Omit
, "href" | "className">;
+
+type CtaLinkAsButton = {
+ href?: never;
+ onClick?: React.MouseEventHandler;
+ type?: "button" | "submit" | "reset";
+} & Omit<
+ React.ButtonHTMLAttributes,
+ "onClick" | "type" | "className"
+>;
+
+type CtaLinkProps = {
+ className?: string;
+ disabled?: boolean;
+ children: React.ReactNode;
+} & (CtaLinkAsLink | CtaLinkAsButton);
+
+export function CtaLink({
+ className,
+ disabled,
+ children,
+ ...props
+}: CtaLinkProps) {
+ const composed = cn(baseClasses, disabled && disabledClasses, className);
+
+ if ("href" in props && props.href !== undefined) {
+ const { href, ...rest } = props;
+ return (
+
+ {children}
+
+ );
+ }
+
+ const { type = "button", onClick, ...rest } = props as CtaLinkAsButton;
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/database/columns.tsx b/components/database/columns.tsx
new file mode 100644
index 0000000..685acee
--- /dev/null
+++ b/components/database/columns.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import type { ColumnDef } from "@tanstack/react-table";
+import { Badge } from "@/components/ui/badge";
+import { formatClockDuration, relativeTime } from "@/lib/format";
+import type {
+ AIAnalysisEvent,
+ AIAnalysisJob,
+ Asset,
+ Camera,
+} from "@/lib/supabase";
+
+const statusVariant: Record = {
+ active: "default",
+ succeeded: "default",
+ ready: "default",
+ idle: "secondary",
+ queued: "secondary",
+ processing: "secondary",
+ disabled: "outline",
+ failed: "outline",
+};
+
+const severityClass: Record = {
+ High: "bg-red-500/15 text-red-500 border-red-500/30",
+ Medium:
+ "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
+ Minor: "bg-muted text-muted-foreground border-border",
+};
+
+function StatusBadge({ status }: { status: string }) {
+ return {status} ;
+}
+
+function MonoId({ value }: { value: string | number }) {
+ return (
+ {value}
+ );
+}
+
+export const cameraColumns: ColumnDef[] = [
+ { accessorKey: "camera_name", header: "Name" },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "last_connected_at",
+ header: "Last connected",
+ cell: ({ row }) => relativeTime(row.original.last_connected_at),
+ },
+ {
+ accessorKey: "id",
+ header: "ID",
+ cell: ({ row }) => ,
+ },
+];
+
+export const assetColumns: ColumnDef[] = [
+ {
+ id: "title",
+ header: "Title",
+ accessorFn: (a) => a.meta?.title ?? a.id,
+ cell: ({ row }) => row.original.meta?.title ?? row.original.id,
+ },
+ {
+ accessorKey: "duration_seconds",
+ header: "Duration",
+ cell: ({ row }) => formatClockDuration(row.original.duration_seconds),
+ },
+ { accessorKey: "resolution_tier", header: "Resolution" },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "created_at",
+ header: "Created",
+ cell: ({ row }) => relativeTime(row.original.created_at),
+ },
+];
+
+export const eventColumns: ColumnDef[] = [
+ { accessorKey: "name", header: "Event" },
+ { accessorKey: "type", header: "Type" },
+ {
+ accessorKey: "severity",
+ header: "Severity",
+ cell: ({ row }) => (
+
+ {row.original.severity}
+
+ ),
+ },
+ {
+ accessorKey: "asset_id",
+ header: "Asset",
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "created_at",
+ header: "Detected",
+ cell: ({ row }) => relativeTime(row.original.created_at),
+ },
+];
+
+export const jobColumns: ColumnDef[] = [
+ {
+ accessorKey: "id",
+ header: "Job",
+ cell: ({ row }) => (
+ #{row.original.id}
+ ),
+ },
+ { accessorKey: "source_type", header: "Source" },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => ,
+ },
+ { accessorKey: "attempts", header: "Attempts" },
+ {
+ accessorKey: "created_at",
+ header: "Created",
+ cell: ({ row }) => relativeTime(row.original.created_at),
+ },
+];
diff --git a/components/database/data-table.tsx b/components/database/data-table.tsx
new file mode 100644
index 0000000..c55f7a8
--- /dev/null
+++ b/components/database/data-table.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { IconArrowsSort, IconSearch } from "@tabler/icons-react";
+import {
+ type ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+
+interface DataTableProps {
+ columns: ColumnDef[];
+ data: T[];
+ searchPlaceholder?: string;
+ className?: string;
+}
+
+export function DataTable({
+ columns,
+ data,
+ searchPlaceholder = "Search…",
+ className,
+}: DataTableProps) {
+ const [sorting, setSorting] = useState([]);
+ const [globalFilter, setGlobalFilter] = useState("");
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: { sorting, globalFilter },
+ onSortingChange: setSorting,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ });
+
+ return (
+
+
+
+ setGlobalFilter(e.target.value)}
+ placeholder={searchPlaceholder}
+ className="h-9 pl-8"
+ />
+
+
+
+
+ {table.getHeaderGroups().map((hg) => (
+
+ {hg.headers.map((header) => {
+ const canSort = header.column.getCanSort();
+ return (
+
+ {header.isPlaceholder ? null : (
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ {canSort && (
+
+ )}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+ {table.getFilteredRowModel().rows.length} of {data.length} rows
+
+
+ );
+}
diff --git a/components/database/stat-tile.tsx b/components/database/stat-tile.tsx
new file mode 100644
index 0000000..476cae5
--- /dev/null
+++ b/components/database/stat-tile.tsx
@@ -0,0 +1,23 @@
+import { cn } from "@/lib/utils";
+
+interface StatTileProps {
+ label: string;
+ value: number;
+ className?: string;
+}
+
+export function StatTile({ label, value, className }: StatTileProps) {
+ return (
+
+
+ {value}
+
+ {label}
+
+ );
+}
diff --git a/components/demo/demo-disabled-notice.tsx b/components/demo/demo-disabled-notice.tsx
new file mode 100644
index 0000000..4912fd8
--- /dev/null
+++ b/components/demo/demo-disabled-notice.tsx
@@ -0,0 +1,34 @@
+import { IconInfoCircle } from "@tabler/icons-react";
+
+export function DemoDisabledNotice({
+ title = "Live streaming is disabled in this demo",
+ description = "The browser → RTMP → Mux pipeline needs a persistent server and isn't available on the demo deployment. The rest of Argus is fully functional with sample data.",
+}: {
+ title?: string;
+ description?: string;
+}) {
+ return (
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ );
+}
+
+export function DemoBadge() {
+ return (
+
+
+ Demo
+
+ );
+}
diff --git a/components/fancy/text/decrypted-text.tsx b/components/fancy/text/decrypted-text.tsx
deleted file mode 100644
index 5b0d664..0000000
--- a/components/fancy/text/decrypted-text.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-
-type DecryptedTextProps = {
- text: string;
- revealDurationMs?: number;
- scrambleSpeed?: number;
- characters?: string;
- className?: string;
-};
-
-export default function DecryptedText({
- text,
- revealDurationMs = 1500,
- scrambleSpeed = 30,
- characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}[]<>?",
- className,
-}: DecryptedTextProps) {
- const [display, setDisplay] = useState(text);
- const startTsRef = useRef(null);
- const rafRef = useRef(null);
- const intervalRef = useRef | null>(null);
-
- const charPool = useMemo(() => characters.split(""), [characters]);
-
- useEffect(() => {
- // Reset state when text changes
- setDisplay(text);
- startTsRef.current = null;
-
- const tick = (ts: number) => {
- if (startTsRef.current == null) startTsRef.current = ts;
- const elapsed = ts - startTsRef.current;
- const progress = Math.min(1, elapsed / revealDurationMs);
- const revealCount = Math.floor(progress * text.length);
-
- setDisplay((prev) => {
- const revealed = text.slice(0, revealCount);
- const remaining = text
- .slice(revealCount)
- .split("")
- .map((ch) => (ch === " " ? " " : charPool[Math.floor(Math.random() * charPool.length)]))
- .join("");
- return revealed + remaining;
- });
-
- if (progress < 1) {
- rafRef.current = requestAnimationFrame(tick);
- } else {
- setDisplay(text);
- }
- };
-
- // Keep scrambling unrevealed characters between RAF frames for a smoother effect
- intervalRef.current = setInterval(() => {
- if (startTsRef.current == null) return;
- const elapsed = performance.now() - startTsRef.current;
- const progress = Math.min(1, elapsed / revealDurationMs);
- const revealCount = Math.floor(progress * text.length);
- setDisplay((prev) => {
- const revealed = text.slice(0, revealCount);
- const remaining = text
- .slice(revealCount)
- .split("")
- .map((ch) => (ch === " " ? " " : charPool[Math.floor(Math.random() * charPool.length)]))
- .join("");
- return revealed + remaining;
- });
- }, scrambleSpeed);
-
- rafRef.current = requestAnimationFrame(tick);
-
- return () => {
- if (rafRef.current) cancelAnimationFrame(rafRef.current);
- if (intervalRef.current) clearInterval(intervalRef.current);
- };
- }, [text, revealDurationMs, scrambleSpeed, charPool]);
-
- return {display} ;
-}
-
-
diff --git a/components/fancy/text/scramble-hover.tsx b/components/fancy/text/scramble-hover.tsx
deleted file mode 100644
index 6ae8025..0000000
--- a/components/fancy/text/scramble-hover.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-"use client"
-
-import { useEffect, useState } from "react"
-import { motion } from "motion/react"
-
-import { cn } from "@/lib/utils"
-
-interface ScrambleHoverProps {
- text: string
- scrambleSpeed?: number
- maxIterations?: number
- sequential?: boolean
- revealDirection?: "start" | "end" | "center"
- useOriginalCharsOnly?: boolean
- characters?: string
- className?: string
- scrambledClassName?: string
-}
-
-const ScrambleHover: React.FC = ({
- text,
- scrambleSpeed = 50,
- maxIterations = 10,
- useOriginalCharsOnly = false,
- characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+",
- className,
- scrambledClassName,
- sequential = false,
- revealDirection = "start",
- ...props
-}) => {
- const [displayText, setDisplayText] = useState(text)
- const [isHovering, setIsHovering] = useState(false)
- const [isScrambling, setIsScrambling] = useState(false)
- const [revealedIndices, setRevealedIndices] = useState(new Set())
-
- useEffect(() => {
- let interval: NodeJS.Timeout
- let currentIteration = 0
-
- const getNextIndex = () => {
- const textLength = text.length
- switch (revealDirection) {
- case "start":
- return revealedIndices.size
- case "end":
- return textLength - 1 - revealedIndices.size
- case "center":
- const middle = Math.floor(textLength / 2)
- const offset = Math.floor(revealedIndices.size / 2)
- const nextIndex =
- revealedIndices.size % 2 === 0
- ? middle + offset
- : middle - offset - 1
-
- if (
- nextIndex >= 0 &&
- nextIndex < textLength &&
- !revealedIndices.has(nextIndex)
- ) {
- return nextIndex
- }
-
- for (let i = 0; i < textLength; i++) {
- if (!revealedIndices.has(i)) return i
- }
- return 0
- default:
- return revealedIndices.size
- }
- }
-
- const shuffleText = (text: string) => {
- if (useOriginalCharsOnly) {
- const positions = text.split("").map((char, i) => ({
- char,
- isSpace: char === " ",
- index: i,
- isRevealed: revealedIndices.has(i),
- }))
-
- const nonSpaceChars = positions
- .filter((p) => !p.isSpace && !p.isRevealed)
- .map((p) => p.char)
-
- // Shuffle remaining non-revealed, non-space characters
- for (let i = nonSpaceChars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1))
- ;[nonSpaceChars[i], nonSpaceChars[j]] = [
- nonSpaceChars[j],
- nonSpaceChars[i],
- ]
- }
-
- let charIndex = 0
- return positions
- .map((p) => {
- if (p.isSpace) return " "
- if (p.isRevealed) return text[p.index]
- return nonSpaceChars[charIndex++]
- })
- .join("")
- } else {
- return text
- .split("")
- .map((char, i) => {
- if (char === " ") return " "
- if (revealedIndices.has(i)) return text[i]
- return availableChars[
- Math.floor(Math.random() * availableChars.length)
- ]
- })
- .join("")
- }
- }
-
- const availableChars = useOriginalCharsOnly
- ? Array.from(new Set(text.split(""))).filter((char) => char !== " ")
- : characters.split("")
-
- if (isHovering) {
- setIsScrambling(true)
- interval = setInterval(() => {
- if (sequential) {
- if (revealedIndices.size < text.length) {
- const nextIndex = getNextIndex()
- revealedIndices.add(nextIndex)
- setDisplayText(shuffleText(text))
- } else {
- clearInterval(interval)
- setIsScrambling(false)
- }
- } else {
- setDisplayText(shuffleText(text))
- currentIteration++
- if (currentIteration >= maxIterations) {
- clearInterval(interval)
- setIsScrambling(false)
- setDisplayText(text)
- }
- }
- }, scrambleSpeed)
- } else {
- setDisplayText(text)
- revealedIndices.clear()
- }
-
- return () => {
- if (interval) clearInterval(interval)
- }
- }, [
- isHovering,
- text,
- characters,
- scrambleSpeed,
- useOriginalCharsOnly,
- sequential,
- revealDirection,
- maxIterations,
- ])
-
- return (
- setIsHovering(true)}
- onHoverEnd={() => setIsHovering(false)}
- className={cn("inline-block whitespace-pre-wrap", className)}
- {...props}
- >
- {displayText}
-
- {displayText.split("").map((char, index) => (
-
- {char}
-
- ))}
-
-
- )
-}
-
-export default ScrambleHover
diff --git a/components/fancy/text/text-highlighter.tsx b/components/fancy/text/text-highlighter.tsx
deleted file mode 100644
index 0e7d035..0000000
--- a/components/fancy/text/text-highlighter.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-"use client"
-
-import {
- ElementType,
- forwardRef,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
-} from "react"
-import { motion, Transition, useInView, UseInViewOptions } from "motion/react"
-
-import { cn } from "@/lib/utils"
-
-type HighlightDirection = "ltr" | "rtl" | "ttb" | "btt"
-
-type TextHighlighterProps = {
- /**
- * The text content to be highlighted
- */
- children: React.ReactNode
-
- /**
- * HTML element to render as
- * @default "p"
- */
- as?: ElementType
-
- /**
- * How to trigger the animation
- * @default "inView"
- */
- triggerType?: "hover" | "ref" | "inView" | "auto"
-
- /**
- * Animation transition configuration
- * @default { duration: 0.4, type: "spring", bounce: 0 }
- */
- transition?: Transition
-
- /**
- * Options for useInView hook when triggerType is "inView"
- */
- useInViewOptions?: UseInViewOptions
-
- /**
- * Class name for the container element
- */
- className?: string
-
- /**
- * Highlight color (CSS color string). Also can be a function that returns a color string, eg:
- * @default 'hsl(60, 90%, 68%)' (yellow)
- */
- highlightColor?: string
-
- /**
- * Direction of the highlight animation
- * @default "ltr" (left to right)
- */
- direction?: HighlightDirection
-} & React.HTMLAttributes
-
-export type TextHighlighterRef = {
- /**
- * Trigger the highlight animation
- * @param direction - Optional direction override for this animation
- */
- animate: (direction?: HighlightDirection) => void
-
- /**
- * Reset the highlight animation
- */
- reset: () => void
-}
-
-export const TextHighlighter = forwardRef<
- TextHighlighterRef,
- TextHighlighterProps
->(
- (
- {
- children,
- as = "span",
- triggerType = "inView",
- transition = { type: "spring", duration: 1, delay: 0, bounce: 0 },
- useInViewOptions = {
- once: true,
- initial: false,
- amount: 0.1,
- },
- className,
- highlightColor = "hsl(25, 90%, 80%)",
- direction = "ltr",
- ...props
- },
- ref
- ) => {
- const componentRef = useRef(null)
- const [isAnimating, setIsAnimating] = useState(false)
- const [isHovered, setIsHovered] = useState(false)
- const [currentDirection, setCurrentDirection] =
- useState(direction)
-
- // this allows us to change the direction whenever the direction prop changes
- useEffect(() => {
- setCurrentDirection(direction)
- }, [direction])
-
- const isInView =
- triggerType === "inView"
- ? useInView(componentRef, useInViewOptions)
- : false
-
- useImperativeHandle(ref, () => ({
- animate: (animationDirection?: HighlightDirection) => {
- if (animationDirection) {
- setCurrentDirection(animationDirection)
- }
- setIsAnimating(true)
- },
- reset: () => setIsAnimating(false),
- }))
-
- const shouldAnimate =
- triggerType === "hover"
- ? isHovered
- : triggerType === "inView"
- ? isInView
- : triggerType === "ref"
- ? isAnimating
- : triggerType === "auto"
- ? true
- : false
-
- const ElementTag = as || "span"
-
- // Get background size based on direction
- const getBackgroundSize = (animated: boolean) => {
- switch (currentDirection) {
- case "ltr":
- return animated ? "100% 100%" : "0% 100%"
- case "rtl":
- return animated ? "100% 100%" : "0% 100%"
- case "ttb":
- return animated ? "100% 100%" : "100% 0%"
- case "btt":
- return animated ? "100% 100%" : "100% 0%"
- default:
- return animated ? "100% 100%" : "0% 100%"
- }
- }
-
- // Get background position based on direction
- const getBackgroundPosition = () => {
- switch (currentDirection) {
- case "ltr":
- return "0% 0%"
- case "rtl":
- return "100% 0%"
- case "ttb":
- return "0% 0%"
- case "btt":
- return "0% 100%"
- default:
- return "0% 0%"
- }
- }
-
- const animatedSize = useMemo(() => getBackgroundSize(shouldAnimate), [shouldAnimate, currentDirection])
- const initialSize = useMemo(() => getBackgroundSize(false), [currentDirection])
- const backgroundPosition = useMemo(() => getBackgroundPosition(), [currentDirection])
-
- const highlightStyle = {
- backgroundImage: `linear-gradient(${highlightColor}, ${highlightColor})`,
- backgroundRepeat: "no-repeat",
- backgroundPosition: backgroundPosition,
- backgroundSize: animatedSize,
- boxDecorationBreak: "clone",
- WebkitBoxDecorationBreak: "clone",
- } as React.CSSProperties
-
- return (
- triggerType === "hover" && setIsHovered(true)}
- onMouseLeave={() => triggerType === "hover" && setIsHovered(false)}
- {...props}
- >
-
- {children}
-
-
- )
- }
-)
-
-TextHighlighter.displayName = "TextHighlighter"
-
-export default TextHighlighter
diff --git a/components/features-alternating.tsx b/components/features-alternating.tsx
deleted file mode 100644
index 98e8ec6..0000000
--- a/components/features-alternating.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { gsap } from "gsap";
-import { ScrollTrigger } from "gsap/ScrollTrigger";
-import { LuxeCard, LuxeCardHeader, LuxeCardTitle, LuxeCardDescription } from "@/components/ui/luxe-card";
-
-gsap.registerPlugin(ScrollTrigger);
-
-interface FeaturesAlternatingProps {
- features: Array<{
- title: string;
- description: string;
- content: React.ReactNode;
- }>;
-}
-
-export function FeaturesAlternating({
- features,
-}: FeaturesAlternatingProps) {
- const sectionRefs = useRef<(HTMLDivElement | null)[]>([]);
-
- useEffect(() => {
- sectionRefs.current.forEach((section, index) => {
- if (!section) return;
-
- const isEven = index % 2 === 0;
- const cardElement = section.querySelector('.feature-card');
- const contentElement = section.querySelector('.feature-content');
-
- // Animate card sliding in from its side
- if (cardElement) {
- gsap.fromTo(
- cardElement,
- {
- x: isEven ? -100 : 100,
- opacity: 0,
- },
- {
- x: 0,
- opacity: 1,
- duration: 0.8,
- ease: "power3.out",
- scrollTrigger: {
- trigger: section,
- start: "top 80%",
- end: "top 50%",
- toggleActions: "play none none reverse",
- },
- }
- );
- }
-
- // Animate content sliding in from opposite side
- if (contentElement) {
- gsap.fromTo(
- contentElement,
- {
- x: isEven ? 100 : -100,
- opacity: 0,
- },
- {
- x: 0,
- opacity: 1,
- duration: 0.8,
- ease: "power3.out",
- scrollTrigger: {
- trigger: section,
- start: "top 80%",
- end: "top 50%",
- toggleActions: "play none none reverse",
- },
- }
- );
- }
- });
-
- return () => {
- ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
- };
- }, [features]);
-
- return (
-
- {features.map((feature, index) => {
- const isEven = index % 2 === 0;
-
- return (
-
{
- sectionRefs.current[index] = el;
- }}
- className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12 items-start"
- >
- {isEven ? (
- <>
- {/* Left: Text Content */}
-
-
-
- {feature.title}
-
-
- {feature.description}
-
-
-
-
- {/* Right: Browser Component */}
-
- {feature.content}
-
- >
- ) : (
- <>
- {/* Left: Browser Component */}
-
- {feature.content}
-
-
- {/* Right: Text Content */}
-
-
-
- {feature.title}
-
-
- {feature.description}
-
-
-
- >
- )}
-
- );
- })}
-
- );
-}
-
diff --git a/components/jobs/columns.tsx b/components/jobs/columns.tsx
index 810fab5..beadb88 100644
--- a/components/jobs/columns.tsx
+++ b/components/jobs/columns.tsx
@@ -4,7 +4,7 @@ import {
IconExclamationCircle,
IconReportAnalytics,
} from "@tabler/icons-react";
-import type { ColumnDef } from "@tanstack/react-table";
+import type { ColumnDef, HeaderContext } from "@tanstack/react-table";
import { ArrowUpDown, Copy, Eye, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
@@ -49,54 +49,47 @@ function formatTimestamp(timestamp: string): string {
});
}
-function formatDuration(
+function formatTimeWindow(
startEpoch: number,
endEpoch: number,
sourceType: string,
): string {
+ // VOD stores relative seconds; live stores Unix epochs.
if (sourceType === "vod") {
- // For VOD, these are relative seconds
return `${startEpoch}s - ${endEpoch}s`;
- } else {
- // For live, these are Unix epochs
- const duration = endEpoch - startEpoch;
- return `${duration}s segment`;
}
+ return `${endEpoch - startEpoch}s segment`;
+}
+
+/** Header renderer for a sortable column: a ghost button that toggles sorting. */
+function sortableHeader(label: string) {
+ return function SortableColumnHeader({
+ column,
+ }: HeaderContext) {
+ return (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ {label}
+
+
+ );
+ };
}
export const columns: ColumnDef[] = [
{
accessorKey: "id",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Job ID
-
-
- );
- },
+ header: sortableHeader("Job ID"),
cell: ({ row }) => (
#{row.getValue("id")}
),
},
{
accessorKey: "status",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Status
-
-
- );
- },
+ header: sortableHeader("Status"),
cell: ({ row }) => {
const status = row.getValue("status") as AIAnalysisJob["status"];
return (
@@ -148,25 +141,33 @@ export const columns: ColumnDef[] = [
const sourceType = row.original.source_type;
return (
- {formatDuration(startEpoch, endEpoch, sourceType)}
+ {formatTimeWindow(startEpoch, endEpoch, sourceType)}
);
},
},
{
- accessorKey: "attempts",
- header: ({ column }) => {
+ id: "models",
+ header: "Models",
+ cell: ({ row }) => {
+ const models = row.original.models ?? [];
+ if (models.length === 0) {
+ return — ;
+ }
return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Attempts
-
-
+
+ {models.map((m) => (
+
+ {m.split(/[:—]/)[0].trim()}
+
+ ))}
+
);
},
+ },
+ {
+ accessorKey: "attempts",
+ header: sortableHeader("Attempts"),
cell: ({ row }) => {
const attempts = row.getValue("attempts") as number;
return (
@@ -180,47 +181,21 @@ export const columns: ColumnDef[] = [
},
{
accessorKey: "created_at",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Created
-
-
- );
- },
- cell: ({ row }) => {
- return (
-
- {formatTimestamp(row.getValue("created_at"))}
-
- );
- },
+ header: sortableHeader("Created"),
+ cell: ({ row }) => (
+
+ {formatTimestamp(row.getValue("created_at"))}
+
+ ),
},
{
accessorKey: "updated_at",
- header: ({ column }) => {
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- Updated
-
-
- );
- },
- cell: ({ row }) => {
- return (
-
- {formatTimestamp(row.getValue("updated_at"))}
-
- );
- },
+ header: sortableHeader("Updated"),
+ cell: ({ row }) => (
+
+ {formatTimestamp(row.getValue("updated_at"))}
+
+ ),
},
{
id: "actions",
diff --git a/components/landing-animated-tabs.tsx b/components/landing-animated-tabs.tsx
deleted file mode 100644
index f9894ec..0000000
--- a/components/landing-animated-tabs.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-"use client"; // @NOTE: Add in case you are using Next.js
-
-import { useEffect, useRef } from "react";
-
-import { cn } from "@/utils/cn";
-
-type AnimatedTabsProps = {
- tabs: Array;
- activeTab: string;
- onTabChange: (tab: string) => void;
-};
-
-export function AnimatedTabs({ tabs, activeTab, onTabChange }: AnimatedTabsProps) {
-
- const containerRef = useRef(null);
- const activeTabRef = useRef(null);
-
- useEffect(() => {
- const container = containerRef.current;
-
- if (container && activeTab) {
- const activeTabElement = activeTabRef.current;
-
- if (activeTabElement) {
- const { offsetLeft, offsetWidth } = activeTabElement;
-
- const clipLeft = offsetLeft;
- const clipRight = offsetLeft + offsetWidth;
-
- container.style.clipPath = `inset(0 ${Number(100 - (clipRight / container.offsetWidth) * 100).toFixed()}% 0 ${Number((clipLeft / container.offsetWidth) * 100).toFixed()}% round 17px)`;
- }
- }
- }, [activeTab]);
-
- return (
-
-
-
- {tabs.map((tab, index) => (
- onTabChange(tab)}
- className={cn(
- "flex h-8 items-center rounded-full p-3 font-medium text-primary-invert text-sm/5.5 max-sm:last:hidden",
- )}
- tabIndex={-1}
- >
- {tab}
-
- ))}
-
-
-
- {tabs.map((tab, index) => {
- const isActive = activeTab === tab;
-
- return (
- onTabChange(tab)}
- className="flex h-8 items-center rounded-full p-3 font-medium text-primary-muted text-sm/5.5 max-sm:last:hidden"
- >
- {tab}
-
- );
- })}
-
-
- );
-}
diff --git a/components/landing/braille-bg.tsx b/components/landing/braille-bg.tsx
new file mode 100644
index 0000000..37c1612
--- /dev/null
+++ b/components/landing/braille-bg.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export type BrailleVariant = "matrix" | "wave" | "pulse" | "vortex";
+
+export const DENSITY_CHARS = Array.from({ length: 256 }, (_, i) => {
+ let count = 0;
+ for (let b = 0; b < 8; b++) {
+ if ((i >> b) & 1) count++;
+ }
+ return { char: String.fromCharCode(0x2800 + i), count };
+})
+ .sort((a, b) => a.count - b.count)
+ .map((x) => x.char);
+
+const VARIANT_STYLES: Record<
+ BrailleVariant,
+ { colorClass: string; duration: string }
+> = {
+ matrix: {
+ colorClass: "from-emerald-400/90 via-teal-500/70 to-emerald-600/90",
+ duration: "8s",
+ },
+ wave: {
+ colorClass: "from-orange-400/90 via-amber-500/70 to-rose-600/90",
+ duration: "12s",
+ },
+ pulse: {
+ colorClass: "from-indigo-400/90 via-purple-500/70 to-blue-600/90",
+ duration: "10s",
+ },
+ vortex: {
+ colorClass: "from-yellow-300/90 via-rose-500/80 to-red-700/90",
+ duration: "6s",
+ },
+};
+
+/**
+ * Shared animation loop that fills a `rows x cols` braille grid on an interval.
+ * `enabled === false` pauses the loop. `compute` returns the density value
+ * (0..1) for a cell given the current frame count.
+ */
+function useBrailleGrid(
+ rows: number,
+ cols: number,
+ enabled: boolean,
+ compute: (
+ r: number,
+ c: number,
+ frameCount: number,
+ cols: number,
+ rows: number,
+ ) => number,
+ deps: React.DependencyList,
+) {
+ const [grid, setGrid] = useState([]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ if (!enabled) return;
+ let frameCount = 0;
+
+ const id = setInterval(() => {
+ frameCount++;
+ const next: string[][] = Array(rows)
+ .fill(null)
+ .map(() => Array(cols).fill(""));
+
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const v = compute(r, c, frameCount, cols, rows);
+ const charIdx = Math.min(255, Math.max(0, Math.floor(v * 255)));
+ next[r][c] = DENSITY_CHARS[charIdx];
+ }
+ }
+ setGrid(next);
+ }, 50);
+
+ return () => clearInterval(id);
+ }, deps);
+
+ return grid;
+}
+
+export function MiniBrailleBg({
+ variant,
+ active,
+}: {
+ variant: BrailleVariant;
+ active: boolean;
+}) {
+ const rows = 25;
+ const cols = 45;
+
+ // `matrix` keeps per-column drop state that advances every frame; recreated
+ // whenever the loop (re)starts via the dependency list below.
+ const grid = useBrailleGrid(
+ rows,
+ cols,
+ active,
+ (() => {
+ const drops = Array.from({ length: cols }, () => ({
+ y: Math.random() * -rows,
+ speed: 0.2 + Math.random() * 0.5,
+ length: 5 + Math.random() * 10,
+ }));
+ let lastFrame = 0;
+
+ return (r, c, frameCount, cols, rows) => {
+ // Advance drops once per frame (on the first cell of each frame).
+ if (variant === "matrix" && frameCount !== lastFrame) {
+ lastFrame = frameCount;
+ for (let i = 0; i < cols; i++) {
+ drops[i].y += drops[i].speed;
+ if (drops[i].y - drops[i].length > rows) {
+ drops[i].y = Math.random() * -10;
+ drops[i].speed = 0.2 + Math.random() * 0.5;
+ }
+ }
+ }
+
+ let v = 0;
+ if (variant === "matrix") {
+ const drop = drops[c];
+ const dist = drop.y - r;
+ if (dist >= 0 && dist < drop.length) {
+ v = 1 - dist / drop.length;
+ v = v * 0.8 + Math.random() * 0.2;
+ } else {
+ v = Math.random() > 0.98 ? Math.random() * 0.2 : 0;
+ }
+ } else if (variant === "wave") {
+ const phase1 = frameCount * 0.1 + c * 0.2;
+ const phase2 = frameCount * 0.05 + c * 0.1;
+ const y1 = Math.sin(phase1) * 5 + rows / 2;
+ const y2 = Math.cos(phase2) * 5 + rows / 2;
+ const dist1 = Math.abs(r - y1);
+ const dist2 = Math.abs(r - y2);
+ const dist = Math.min(dist1, dist2);
+ v = dist < 4 ? 1 - dist / 4 : 0;
+ v = v * (0.7 + Math.random() * 0.3);
+ } else if (variant === "pulse") {
+ const t = frameCount * 0.05;
+ const x1 = cols / 2 + Math.sin(t) * 10;
+ const y1 = rows / 2 + Math.cos(t * 1.3) * 5;
+ const x2 = cols / 2 + Math.cos(t * 0.8) * 15;
+ const y2 = rows / 2 + Math.sin(t * 1.1) * 8;
+ const d1 = Math.sqrt((c - x1) ** 2 + ((r - y1) * 2) ** 2);
+ const d2 = Math.sqrt((c - x2) ** 2 + ((r - y2) * 2) ** 2);
+ v = Math.max(d1 < 12 ? 1 - d1 / 12 : 0, d2 < 12 ? 1 - d2 / 12 : 0);
+ if (v > 0) v = v * 0.8 + Math.random() * 0.2;
+ } else if (variant === "vortex") {
+ const t = frameCount * 0.04;
+ const cx = cols / 2;
+ const cy = rows / 2;
+ const dx = c - cx;
+ const dy = (r - cy) * 2.2;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const angle = Math.atan2(dy, dx);
+ // Two counter-rotating spiral arms tightening toward center
+ const spiralPhase = angle - dist * 0.35 + t * 2.5;
+ const arm = Math.max(0, Math.cos(spiralPhase * 2)) ** 2;
+ const falloff = dist < 22 ? (1 - dist / 22) ** 0.6 : 0;
+ v = arm * falloff;
+ // Inner core glow
+ if (dist < 3) v = Math.max(v, 0.6 + Math.random() * 0.4);
+ if (v > 0) v = v * (0.75 + Math.random() * 0.25);
+ }
+ return v;
+ };
+ })(),
+ [variant, active],
+ );
+
+ const { colorClass, duration } = VARIANT_STYLES[variant];
+
+ return (
+
+
+
+
+ {grid.map((row, i) => (
+
{row.join("")}
+ ))}
+
+
+
+
+ );
+}
+
+export function ButtonBrailleBg() {
+ const grid = useBrailleGrid(
+ 8,
+ 50,
+ true,
+ (r, c, frameCount) => {
+ const x = c * 0.15;
+ const y = r * 0.3;
+ const t = frameCount * 0.06;
+
+ const n1 = Math.sin(x + t);
+ const n2 = Math.cos(y - t * 0.8);
+ const n3 = Math.sin(x * 0.5 + y * 0.5 + t * 1.2);
+
+ const noise = (n1 + n2 + n3) / 3;
+
+ let v = noise * 0.5 + 0.5;
+ v = v > 0.55 ? (v - 0.55) * 2.2 : 0;
+ return v;
+ },
+ [],
+ );
+
+ return (
+
+
+ {grid.map((row, i) => (
+
{row.join("")}
+ ))}
+
+
+ );
+}
diff --git a/components/landing/feature-shot.tsx b/components/landing/feature-shot.tsx
new file mode 100644
index 0000000..3e4f727
--- /dev/null
+++ b/components/landing/feature-shot.tsx
@@ -0,0 +1,32 @@
+import Image from "next/image";
+import { BrowserComponent } from "@/components/browser-component";
+
+export function FeatureShot({
+ url,
+ src,
+ alt,
+ priority = false,
+ className,
+}: {
+ url: string;
+ src: string;
+ alt: string;
+ priority?: boolean;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/components/landing/pricing-section.tsx b/components/landing/pricing-section.tsx
new file mode 100644
index 0000000..8ba31c2
--- /dev/null
+++ b/components/landing/pricing-section.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { Check } from "lucide-react";
+import { useState } from "react";
+import {
+ ButtonBrailleBg,
+ type BrailleVariant,
+ MiniBrailleBg,
+} from "@/components/landing/braille-bg";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { cn } from "@/lib/utils";
+
+const tiers = [
+ {
+ name: "Self-Hosted",
+ monthlyPrice: null as number | null,
+ yearlyPrice: null as number | null,
+ priceLabel: "Free",
+ description:
+ "For developers who want full control over their own infrastructure.",
+ features: [
+ "Bring Your Own Key (BYOK)",
+ "1 camera stream",
+ "Basic AI threat detection",
+ "Community support",
+ "Standard rate limits",
+ ],
+ buttonText: "Deploy for free",
+ variant: "matrix" as BrailleVariant,
+ popular: false,
+ },
+ {
+ name: "Starter",
+ monthlyPrice: 299,
+ yearlyPrice: 239,
+ priceLabel: null as string | null,
+ description:
+ "For small teams securing physical spaces with intelligent monitoring.",
+ features: [
+ "Up to 3 cameras",
+ "AI threat detection (Gemini 2.5 Pro)",
+ "Semantic event search",
+ "AI chat assistant",
+ "30-day event retention",
+ "Basic analytics dashboard",
+ "Email support",
+ ],
+ buttonText: "Get Started",
+ variant: "wave" as BrailleVariant,
+ popular: false,
+ },
+ {
+ name: "Professional",
+ monthlyPrice: 699,
+ yearlyPrice: 559,
+ priceLabel: null as string | null,
+ description:
+ "For security teams that need the full power of agentic AI surveillance.",
+ features: [
+ "Up to 15 cameras",
+ "Full AI model suite (Claude + Gemini)",
+ "Advanced analytics & custom reports",
+ "Automated incident report generation",
+ "Unlimited event retention",
+ "Real-time alerts & notifications",
+ "Job queue management",
+ "Priority support",
+ ],
+ buttonText: "Get Started",
+ variant: "pulse" as BrailleVariant,
+ popular: true,
+ },
+ {
+ name: "Enterprise",
+ monthlyPrice: null as number | null,
+ yearlyPrice: null as number | null,
+ priceLabel: "Custom",
+ description:
+ "For organizations with mission-critical security and compliance requirements.",
+ features: [
+ "Unlimited cameras",
+ "All Professional features",
+ "Custom AI model configuration",
+ "On-premise deployment",
+ "Dedicated account manager",
+ "Custom SLA guarantees",
+ "White-label options",
+ "Full API access",
+ ],
+ buttonText: "Contact Sales",
+ variant: "vortex" as BrailleVariant,
+ popular: false,
+ },
+];
+
+const billingOptions = [
+ { label: "Monthly", yearly: false },
+ { label: "Yearly", yearly: true },
+] as const;
+
+function SegmentedToggle({
+ isYearly,
+ onChange,
+}: {
+ isYearly: boolean;
+ onChange: (yearly: boolean) => void;
+}) {
+ return (
+
+ {billingOptions.map((option) => {
+ const active = option.yearly === isYearly;
+ return (
+ onChange(option.yearly)}
+ className={cn(
+ "text-xs font-bold uppercase tracking-wider px-5 py-2.5",
+ option.yearly && "flex items-center gap-2",
+ active
+ ? "bg-foreground text-background"
+ : "bg-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {option.label}
+ {option.yearly && (
+
+ −20%
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+export function PricingSection({ className }: { className?: string }) {
+ const [isYearly, setIsYearly] = useState(false);
+ const [hoveredIdx, setHoveredIdx] = useState(null);
+ const [noticeOpen, setNoticeOpen] = useState(false);
+
+ return (
+
+
+
+
+
+ Pricing
+
+
+ Per seat. Cancel anytime.
+
+
+
+
+
+
+
+ {tiers.map((tier, idx) => {
+ const isPopular = tier.popular;
+ const price =
+ tier.monthlyPrice !== null
+ ? isYearly
+ ? tier.yearlyPrice
+ : tier.monthlyPrice
+ : null;
+
+ return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: hover only drives a decorative background animation
+
setHoveredIdx(idx)}
+ onMouseLeave={() => setHoveredIdx(null)}
+ className={cn(
+ "flex flex-col justify-between h-full group relative overflow-hidden transition-all duration-300 bg-background",
+ idx < tiers.length - 1 && "border-r border-border",
+ )}
+ >
+
+
+
+
+ {tier.name}
+
+
+
+
+ {price !== null ? `$${price}` : tier.priceLabel}
+
+ {price !== null && (
+
+ /seat/mo
+
+ )}
+
+
+ {price !== null && isYearly && (
+
+ billed annually
+
+ )}
+
+
+ {tier.description}
+
+
+
+ {tier.features.map((feature) => (
+
+
+
+ {feature}
+
+
+ ))}
+
+
+
+
+ setNoticeOpen(true)}
+ className={cn(
+ "relative overflow-hidden w-full px-6 py-4 text-xs font-bold uppercase tracking-wider rounded-none",
+ isPopular
+ ? "bg-foreground text-background hover:bg-foreground/90"
+ : "bg-secondary text-secondary-foreground hover:bg-foreground/10",
+ )}
+ >
+ {isPopular && }
+ {tier.buttonText}
+
+
+
+ );
+ })}
+
+
+
+ All paid plans include a 14-day free trial. No credit card required.
+
+
+
+
+
+
+ Coming soon
+
+ Our general release is still in the works and not quite ready yet.
+ Check back soon.
+
+
+
+ Got it
+
+
+
+
+ );
+}
diff --git a/components/landing/section.tsx b/components/landing/section.tsx
new file mode 100644
index 0000000..4abaa8a
--- /dev/null
+++ b/components/landing/section.tsx
@@ -0,0 +1,28 @@
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Section({ className, ...props }: React.ComponentProps<"section">) {
+ return (
+
+ );
+}
+
+function SectionInner({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Section, SectionInner };
diff --git a/components/landing/style-manifest.tsx b/components/landing/style-manifest.tsx
new file mode 100644
index 0000000..13ad50d
--- /dev/null
+++ b/components/landing/style-manifest.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import Image from "next/image";
+import { CtaLink } from "@/components/cta-link";
+import { PricingSection } from "@/components/landing/pricing-section";
+import { Section, SectionInner } from "@/components/landing/section";
+import { WorkflowPipeline } from "@/components/landing/workflow-pipeline";
+import { SiteFooter } from "@/components/site-footer";
+import { cn } from "@/lib/utils";
+
+export type FeatureItem = {
+ title: string;
+ description: string;
+ content: React.ReactNode;
+};
+
+const aiModels = [
+ { code: "OBJDET", name: "Roboflow 3.0 Object Detection" },
+ { code: "AGENT", name: "Letta Stateful Agent" },
+ { code: "SEARCH", name: "Elasticsearch Agent" },
+ { code: "LLM-A", name: "Groq Kimi K2 Instruct" },
+ { code: "LLM-B", name: "Gemini 2.5 Pro" },
+ { code: "LLM-C", name: "Claude 4.5 Haiku" },
+ { code: "LLM-D", name: "Claude 4.5 Sonnet" },
+];
+
+function ModelsSection() {
+ return (
+
+
+
+
+
+ Technology, catalogued.
+
+
+
+
+ Argus is built on a real-time streaming pipeline — Mux ingest,
+ FFmpeg transmuxing, Roboflow detection at the edge, Gemini
+ summaries, indexed in Elasticsearch and served through Supabase
+ Realtime.
+
+
+ Each component is a working part of that loop — no vapourware, no
+ stand-ins.
+
+
+
+
+
+ {aiModels.map((m) => (
+
+
+ {m.code}
+
+
+ {m.name}
+
+
+ ))}
+
+
+
+ );
+}
+
+function CtaSection() {
+ return (
+
+ );
+}
+
+function FeatureBlock({
+ feature,
+ index,
+ bg,
+ reverse,
+}: {
+ readonly feature: FeatureItem;
+ readonly index: number;
+ readonly bg: string;
+ readonly reverse: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {feature.content}
+
+
+
+
+
+
+ {feature.description}
+
+
+
+
+ );
+}
+
+function FeaturesAlternating({
+ features,
+}: {
+ readonly features: FeatureItem[];
+}) {
+ const backgrounds = [
+ "/assets/bg1.webp",
+ "/assets/bg2.webp",
+ "/assets/bg3.webp",
+ "/assets/bg4.webp",
+ "/assets/bg5.webp",
+ ];
+
+ return (
+
+ {features.map((feature, i) => (
+
+ ))}
+
+ );
+}
+
+export function LandingSections({ features }: { features: FeatureItem[] }) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/landing/workflow-pipeline.tsx b/components/landing/workflow-pipeline.tsx
new file mode 100644
index 0000000..2b050dc
--- /dev/null
+++ b/components/landing/workflow-pipeline.tsx
@@ -0,0 +1,588 @@
+"use client";
+
+import { Collapsible } from "@base-ui/react/collapsible";
+import { Progress } from "@base-ui/react/progress";
+import { Check, Search, Sparkles } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useState } from "react";
+import { Section, SectionInner } from "@/components/landing/section";
+import { cn } from "@/lib/utils";
+
+const DETECTIONS = [
+ {
+ label: "Unattended bag",
+ camera: "Terminal B",
+ severity: "critical",
+ confidence: "91%",
+ time: "14:31:12",
+ },
+ {
+ label: "Loitering flagged",
+ camera: "Main lobby",
+ severity: "warning",
+ confidence: "88%",
+ time: "14:31:38",
+ },
+ {
+ label: "Vehicle entering",
+ camera: "North gate",
+ severity: "info",
+ confidence: "94%",
+ time: "14:31:54",
+ },
+];
+
+const SEARCH_RESULTS = [
+ ["Loitering near loading bay", "CAM-02", "94%", "14:28-14:33"],
+ ["Person re-entered frame", "CAM-02", "89%", "14:36-14:37"],
+ ["Group lingering by door", "CAM-04", "81%", "21:12-21:18"],
+];
+
+const TOOL_STEPS = [
+ {
+ label: "Ingesting Stream...",
+ tool: "mux.ingest_stream",
+ output: 'stream: "CAM-02", status: "recording"',
+ },
+ {
+ label: "Detecting Entities...",
+ tool: "roboflow.detect_objects",
+ output: 'entities: ["worker", "pallet_jack"], confidence: 0.91',
+ },
+ {
+ label: "Detection analysis...",
+ tool: "gemini.summarize_scene",
+ output: 'summary: "worker moving loaded pallet through dock"',
+ },
+ {
+ label: "Adding index to ElasticSearch...",
+ tool: "elasticsearch.index_event",
+ output: 'index: "analysis_events", searchable: true',
+ },
+ {
+ label: "Documenting Event...",
+ tool: "reports.create_event_note",
+ output: 'event: "loading dock activity", status: "documented"',
+ },
+];
+
+const fadeUp = {
+ hidden: { opacity: 0, y: 24 },
+ show: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as const },
+ },
+};
+
+function useReducedMotion() {
+ const [reduced, setReduced] = useState(false);
+
+ useEffect(() => {
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setReduced(query.matches);
+
+ const update = () => setReduced(query.matches);
+ query.addEventListener("change", update);
+ return () => query.removeEventListener("change", update);
+ }, []);
+
+ return reduced;
+}
+
+function useCycle(length: number, intervalMs: number, enabled: boolean) {
+ const [index, setIndex] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const id = window.setInterval(() => {
+ setIndex((previous) => (previous + 1) % length);
+ }, intervalMs);
+ return () => window.clearInterval(id);
+ }, [enabled, intervalMs, length]);
+
+ return index;
+}
+
+function AppShell({
+ children,
+ label,
+}: {
+ children: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+function LiveDot({ tone = "ok" }: { tone?: "ok" | "warn" | "alert" }) {
+ return (
+
+
+
+ );
+}
+
+function WorkflowCopy({
+ step,
+ eyebrow,
+ title,
+ description,
+ points,
+}: {
+ step: string;
+ eyebrow: string;
+ title: string;
+ description: string;
+ points: string[];
+}) {
+ return (
+
+
+
+ {step} / {eyebrow}
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {points.map((point) => (
+
+
+
+
+ {point}
+
+ ))}
+
+
+ );
+}
+
+type ToolStepState = "complete" | "loading" | "queued";
+
+function StepProgress({ state }: { state: ToolStepState }) {
+ const value = state === "complete" ? 100 : state === "loading" ? null : 0;
+
+ return (
+
+
+ {state === "complete" ? (
+
+ ) : null}
+ {state === "loading" ? (
+
+ ) : null}
+ {state === "queued" ? (
+
+ ) : null}
+
+
+ );
+}
+
+function ToolCallStep({
+ item,
+ state,
+}: {
+ item: (typeof TOOL_STEPS)[number];
+ state: ToolStepState;
+}) {
+ const open = state === "loading";
+
+ return (
+
+
+
+
+
+ {item.label}
+
+
+ {item.tool}
+
+
+
+
+
+ {open ? (
+
+ {item.output}
+
+ ) : null}
+
+
+
+ );
+}
+
+function ToolCallCard({
+ name,
+ status = "completed",
+ children,
+}: {
+ name: string;
+ status?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function IngestCard({ active }: { active: number }) {
+ return (
+
+
+
+
+
+ Agent run
+
+
+ video pipeline
+
+
+
+
+ {TOOL_STEPS.map((item, index) => {
+ const state: ToolStepState =
+ index < active
+ ? "complete"
+ : index === active
+ ? "loading"
+ : "queued";
+
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+function DetectionCard({ active }: { active: number }) {
+ const selected = DETECTIONS[active];
+
+ return (
+
+
+
+
+ {`{
+ object: "${selected.label}",
+ camera: "${selected.camera}",
+ confidence: "${selected.confidence}",
+ severity: "${selected.severity}"
+}`}
+
+
+
+
+ );
+}
+
+function SearchCard({ active }: { active: number }) {
+ const [label, camera, score, range] = SEARCH_RESULTS[active];
+
+ return (
+
+
+
+
+
+
+ people loitering near the loading bay after 9pm
+
+
+
+
+
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {label}
+
+
+ {camera} / {range}
+
+
+
+ {score}
+
+
+
+
+
+ );
+}
+
+function AssistantCard({ active }: { active: number }) {
+ const actions = [
+ ["Flagged event", "Warning severity applied"],
+ ["Opened clip", "CAM-05 at 14:31:54"],
+ ["Drafted report", "Ready for supervisor review"],
+ ];
+
+ return (
+
+
+
+
+
+ What happened at the north gate?
+
+
+ Three events, one warning, and one linked clip are ready.
+
+
+
+
+
+
+ {`{
+ summary: "Vehicle idled for five minutes",
+ evidence: "3 clips / 2 cameras",
+ status: "${actions[active][0]}"
+}`}
+
+
+
+
+ );
+}
+
+function WorkflowStory({
+ reverse = false,
+ copy,
+ children,
+}: {
+ reverse?: boolean;
+ copy: React.ComponentProps;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+export function WorkflowPipeline() {
+ const reduced = useReducedMotion();
+ const pipelineStep = useCycle(TOOL_STEPS.length, 6500, !reduced);
+ const detectionStep = useCycle(DETECTIONS.length, 7000, !reduced);
+ const searchStep = useCycle(SEARCH_RESULTS.length, 7200, !reduced);
+ const assistantStep = useCycle(3, 7600, !reduced);
+
+ return (
+
+
+
+
+
Product workflow
+
+ From raw video to review-ready intelligence.
+
+
+
+
+ Argus works like a product workflow, not a wall of camera feeds.
+ Each stream becomes structured events, searchable evidence, and
+ assistant-ready context.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/landing/workflows.tsx b/components/landing/workflows.tsx
new file mode 100644
index 0000000..e5ae4c6
--- /dev/null
+++ b/components/landing/workflows.tsx
@@ -0,0 +1,683 @@
+"use client";
+
+import {
+ Activity,
+ ArrowUpRight,
+ Check,
+ Database,
+ type LucideIcon,
+ Radio,
+ ScanEye,
+ Search,
+ Send,
+ Sparkles,
+ User,
+} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Section, SectionInner } from "@/components/landing/section";
+import { cn } from "@/lib/utils";
+
+/* ───────────────────────── shared data ───────────────────────── */
+
+type Stage = { code: string; label: string; vendor: string; icon: LucideIcon };
+
+const STAGES: Stage[] = [
+ { code: "01", label: "Ingest", vendor: "Mux · RTMP", icon: Radio },
+ { code: "02", label: "Detect", vendor: "Roboflow", icon: ScanEye },
+ { code: "03", label: "Analyze", vendor: "Gemini", icon: Sparkles },
+ { code: "04", label: "Index", vendor: "Elasticsearch", icon: Database },
+ { code: "05", label: "Surface", vendor: "Realtime", icon: Activity },
+];
+
+type Severity = "info" | "warn" | "alert";
+
+type EventDef = {
+ type: string;
+ cam: string;
+ time: string;
+ conf: number;
+ sev: Severity;
+};
+
+const EVENTS: EventDef[] = [
+ { type: "Person detected", cam: "CAM-02 · Loading Bay", time: "14:32:07", conf: 97, sev: "info" },
+ { type: "Vehicle entering", cam: "CAM-05 · North Gate", time: "14:31:54", conf: 94, sev: "info" },
+ { type: "Loitering flagged", cam: "CAM-01 · Main Lobby", time: "14:31:38", conf: 88, sev: "warn" },
+ { type: "Unattended bag", cam: "CAM-07 · Terminal B", time: "14:31:12", conf: 91, sev: "alert" },
+ { type: "Crowd forming", cam: "CAM-03 · West Plaza", time: "14:30:45", conf: 86, sev: "warn" },
+ { type: "Tailgating detected", cam: "CAM-09 · Side Entry", time: "14:30:21", conf: 90, sev: "warn" },
+];
+
+const SEVERITY_STYLES: Record = {
+ info: "text-muted-foreground border-border",
+ warn: "text-amber-600 dark:text-amber-400 border-amber-500/30 bg-amber-500/10",
+ alert: "text-destructive border-destructive/30 bg-destructive/10",
+};
+
+type Query = {
+ text: string;
+ results: { label: string; cam: string; score: number }[];
+};
+
+const QUERIES: Query[] = [
+ {
+ text: "people loitering near the loading bay after 9pm",
+ results: [
+ { label: "Loitering · 4 min dwell", cam: "CAM-02 · Loading Bay", score: 0.94 },
+ { label: "Person re-entered frame", cam: "CAM-02 · Loading Bay", score: 0.89 },
+ { label: "Group lingering by door", cam: "CAM-04 · Dock 3", score: 0.81 },
+ ],
+ },
+ {
+ text: "unattended bags in the terminal today",
+ results: [
+ { label: "Unattended bag · 2 min", cam: "CAM-07 · Terminal B", score: 0.96 },
+ { label: "Object left on bench", cam: "CAM-08 · Gate 12", score: 0.84 },
+ ],
+ },
+ {
+ text: "vehicles idling at the north gate this hour",
+ results: [
+ { label: "Truck idling > 5 min", cam: "CAM-05 · North Gate", score: 0.92 },
+ { label: "Vehicle entering", cam: "CAM-05 · North Gate", score: 0.88 },
+ { label: "Vehicle exiting", cam: "CAM-06 · North Gate", score: 0.8 },
+ ],
+ },
+];
+
+type ChatMessage = { role: "user" | "assistant"; text: string; chips?: string[] };
+
+const CHAT: ChatMessage[] = [
+ { role: "user", text: "What happened at the north gate in the last hour?" },
+ {
+ role: "assistant",
+ text: "Three events. A vehicle entered at 14:31, a truck idled for over five minutes, then a vehicle exited.",
+ chips: ["Vehicle entering", "Truck idling", "Vehicle exiting"],
+ },
+ { role: "user", text: "Flag the idling truck and open the clip." },
+ {
+ role: "assistant",
+ text: "Flagged as a warning and pinned to your review queue. Opening CAM-05 at 14:31:54.",
+ chips: ["CAM-05 · 14:31:54"],
+ },
+];
+
+/* ───────────────────────── helpers ───────────────────────── */
+
+function useReducedMotion() {
+ const [reduced, setReduced] = useState(false);
+ useEffect(() => {
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
+ setReduced(mq.matches);
+ const onChange = () => setReduced(mq.matches);
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, []);
+ return reduced;
+}
+
+function useCycle(length: number, intervalMs: number, enabled: boolean) {
+ const [index, setIndex] = useState(0);
+ useEffect(() => {
+ if (!enabled) return;
+ const id = setInterval(
+ () => setIndex((p) => (p + 1) % length),
+ intervalMs,
+ );
+ return () => clearInterval(id);
+ }, [length, intervalMs, enabled]);
+ return index;
+}
+
+function LiveDot({ color = "var(--linear-success)" }: { color?: string }) {
+ return (
+
+
+
+
+ );
+}
+
+/* ───────────────────────── workflow 1 · pipeline ───────────────────────── */
+
+function PipelineFlow({ reduced }: { reduced: boolean }) {
+ const active = useCycle(STAGES.length, 1100, !reduced);
+ const fillPct = ((active + 1) / STAGES.length) * 100;
+ const dotPct = ((active + 0.5) / STAGES.length) * 100;
+
+ return (
+
+
+ {!reduced && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {STAGES.map((stage, i) => {
+ const isActive = !reduced && i === active;
+ const Icon = stage.icon;
+ return (
+
+ {isActive && (
+
+ )}
+
+
+
+ {stage.code}
+
+
+
+
+ {stage.label}
+
+
+ {stage.vendor}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/* ───────────────────────── workflow 2 · live feed ───────────────────────── */
+
+type FeedItem = { id: number; event: number };
+
+const SEED_FEED: FeedItem[] = [
+ { id: 4, event: 4 },
+ { id: 3, event: 3 },
+ { id: 2, event: 2 },
+ { id: 1, event: 1 },
+ { id: 0, event: 0 },
+];
+
+function EventFeed({ reduced }: { reduced: boolean }) {
+ const [feed, setFeed] = useState(SEED_FEED);
+ const idRef = useRef(SEED_FEED.length);
+ const eventRef = useRef(SEED_FEED.length - 1);
+
+ useEffect(() => {
+ if (reduced) return;
+ const id = setInterval(() => {
+ eventRef.current = (eventRef.current + 1) % EVENTS.length;
+ const next: FeedItem = { id: idRef.current, event: eventRef.current };
+ idRef.current += 1;
+ setFeed((prev) => [next, ...prev].slice(0, 5));
+ }, 2300);
+ return () => clearInterval(id);
+ }, [reduced]);
+
+ const newestId = feed[0]?.id ?? -1;
+
+ return (
+
+
+
+
+ Live detections
+
+
+ {feed.length} events
+
+
+
+ {feed.map((item) => {
+ const e = EVENTS[item.event];
+ return (
+
+
+
+
+
+
+ {e.type}
+
+ {e.sev}
+
+
+
+ {e.cam} · {e.time}
+
+
+
+
+ {e.conf}%
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/* ───────────────────────── workflow 3 · semantic search ───────────────────────── */
+
+function SemanticSearch({ reduced }: { reduced: boolean }) {
+ const [qIndex, setQIndex] = useState(0);
+ const [typed, setTyped] = useState(0);
+ const [showResults, setShowResults] = useState(false);
+
+ useEffect(() => {
+ if (reduced) {
+ setTyped(QUERIES[0].text.length);
+ setShowResults(true);
+ return;
+ }
+ let cancelled = false;
+ const timers: number[] = [];
+ const wait = (ms: number) =>
+ new Promise((resolve) => {
+ timers.push(window.setTimeout(resolve, ms));
+ });
+
+ void (async () => {
+ let qi = 0;
+ while (!cancelled) {
+ setQIndex(qi);
+ setShowResults(false);
+ setTyped(0);
+ const text = QUERIES[qi].text;
+ for (let c = 1; c <= text.length; c++) {
+ if (cancelled) return;
+ setTyped(c);
+ await wait(42);
+ }
+ await wait(450);
+ if (cancelled) return;
+ setShowResults(true);
+ await wait(2800);
+ qi = (qi + 1) % QUERIES.length;
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ timers.forEach((t) => clearTimeout(t));
+ };
+ }, [reduced]);
+
+ const query = QUERIES[qIndex];
+ const typedText = query.text.slice(0, typed);
+
+ return (
+
+
+
+
+
+ {typedText}
+ {!reduced && (
+
+ )}
+
+
+
+
+
+
+ {showResults ? `${query.results.length} matches` : "Searching…"}
+
+
+ Elasticsearch agent
+
+
+
+ {showResults
+ ? query.results.map((r, i) => (
+
+
+
+ {(r.score * 100).toFixed(0)}% match
+
+
+
+
+ {r.label}
+
+
+ {r.cam}
+
+
+ ))
+ : Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+/* ───────────────────────── workflow 4 · assistant ───────────────────────── */
+
+function ChatBubble({ message }: { message: ChatMessage }) {
+ const isUser = message.role === "user";
+ return (
+
+
+ {isUser ? (
+
+ ) : (
+
+ )}
+
+
+
+ {message.text}
+
+ {message.chips && (
+
+ {message.chips.map((chip) => (
+
+ {chip}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function TypingBubble() {
+ return (
+
+ );
+}
+
+function AssistantChat({ reduced }: { reduced: boolean }) {
+ const [shown, setShown] = useState(reduced ? CHAT.length : 0);
+ const [typing, setTyping] = useState(false);
+
+ useEffect(() => {
+ if (reduced) return;
+ let cancelled = false;
+ const timers: number[] = [];
+ const wait = (ms: number) =>
+ new Promise((resolve) => {
+ timers.push(window.setTimeout(resolve, ms));
+ });
+
+ void (async () => {
+ while (!cancelled) {
+ setShown(0);
+ setTyping(false);
+ await wait(700);
+ for (let i = 0; i < CHAT.length; i++) {
+ if (cancelled) return;
+ if (CHAT[i].role === "assistant") {
+ setTyping(true);
+ await wait(1100);
+ if (cancelled) return;
+ setTyping(false);
+ } else {
+ await wait(500);
+ }
+ if (cancelled) return;
+ setShown(i + 1);
+ await wait(CHAT[i].role === "assistant" ? 1500 : 900);
+ }
+ await wait(3000);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ timers.forEach((t) => clearTimeout(t));
+ };
+ }, [reduced]);
+
+ return (
+
+
+
+
+ AI assistant
+
+
+ Claude · Gemini
+
+
+
+ {CHAT.slice(0, shown).map((message, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed scripted transcript
+
+ ))}
+ {typing && }
+
+
+
+
+ Ask anything…
+
+
+
+
+
+
+
+ );
+}
+
+/* ───────────────────────── row layout ───────────────────────── */
+
+function WorkflowRow({
+ eyebrow,
+ title,
+ description,
+ points,
+ children,
+}: {
+ eyebrow: string;
+ title: string;
+ description: string;
+ points: string[];
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {points.map((point) => (
+
+
+ {point}
+
+ ))}
+
+
+
{children}
+
+ );
+}
+
+/* ───────────────────────── section ───────────────────────── */
+
+export function Workflows() {
+ const reduced = useReducedMotion();
+
+ return (
+
+
+
+
+
+ How it works
+
+
+ The system, in motion.
+
+
+
+
+ Four live workflows — the ingest pipeline, the detection feed,
+ semantic search, and the AI assistant — running the same loop
+ you'd see in production.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/models-section.tsx b/components/models-section.tsx
deleted file mode 100644
index 85ccd91..0000000
--- a/components/models-section.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-"use client";
-
-import TextHighlighter from "@/components/fancy/text/text-highlighter";
-import ScrambleHover from "@/components/fancy/text/scramble-hover";
-import { Badge } from "@/components/ui/badge";
-
-const aiModels = [
- "Letta Stateful Agent",
- "Elasticsearch Agent",
- "Groq Kimi K2 Instruct",
- "Gemini 2.5 Pro",
- "Claude 4.5 Haiku",
- "Claude 4.5 Sonnet",
- "Roboflow 3.0 Object Detection",
-];
-
-export function ModelsSection() {
- return (
-
-
- {/* Left: Text with Highlighter */}
-
-
- Argus leverages the{" "}
-
- best in AI technology
-
- , combining cutting-edge{" "}
-
- computer vision
- {" "}
- and{" "}
-
- natural language processing
- {" "}
- to deliver unparalleled{" "}
-
- security and surveillance
- {" "}
- capabilities. Argus intelligently analyzes video feeds in{" "}
-
- real-time
- , detecting {" "}
-
- anomalies
- , identifying threats, and generating{" "}
-
- comprehensive reports
- to keep you informed and protected.
-
-
-
-
-
- Built on a {" "}
-
- real-time streaming pipeline
- {" "}
- with {" "}
-
- Mux
- {" "}
- and {" "}
-
- FFmpeg transmuxing
-
- , Argus delivers {" "}
-
- low-latency playback
- {" "}
- and resilient delivery while segments are scheduled and analyzed at the edge.
-
-
-
- Detections from {" "}
-
- Roboflow object detection
- {" "}
- and AI summaries from {" "}
-
- Gemini
- {" "}
- are indexed into {" "}
-
- Elasticsearch
- {" "}
- with rich {" "}
-
- structured metadata
-
- , and persisted via {" "}
-
- Supabase Realtime & Edge Functions
- {" "}
- so you're always in the loop.
-
-
-
- {/* Right: AI Models List */}
-
-
-
- Powered by
-
-
-
- {aiModels.map((model, index) => (
-
-
-
- ))}
-
-
-
-
- );
-}
-
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
index 0fd8049..8fc067a 100644
--- a/components/nav-main.tsx
+++ b/components/nav-main.tsx
@@ -14,7 +14,8 @@ import {
export function NavMain({
items,
-}: {
+ labelClassName,
+}: Readonly<{
items: {
title?: string;
items: {
@@ -23,7 +24,8 @@ export function NavMain({
icon?: Icon;
}[];
}[];
-}) {
+ labelClassName?: string;
+}>) {
const pathname = usePathname();
return (
@@ -31,7 +33,9 @@ export function NavMain({
{items.map((section) => (
{section.title && (
- {section.title}
+
+ {section.title}
+
)}
diff --git a/components/onboarding/apply-tool-event.ts b/components/onboarding/apply-tool-event.ts
new file mode 100644
index 0000000..78c08da
--- /dev/null
+++ b/components/onboarding/apply-tool-event.ts
@@ -0,0 +1,55 @@
+import type { OnboardingToolEvent } from "@/components/onboarding/onboarding-types";
+import {
+ addCamera,
+ addDetectionRule,
+ setAlerts,
+ setOrgName,
+} from "@/lib/demo/session-store";
+
+/**
+ * Apply a single onboarding tool event to the demo-session store.
+ *
+ * Pure with respect to UI: it performs the store mutations a tool call implies
+ * and returns `true` when the event signals that onboarding is complete (so the
+ * caller can advance to the confirm phase). It does not deduplicate — callers
+ * are responsible for invoking each event at most once.
+ */
+export function applyToolEvent(event: OnboardingToolEvent): boolean {
+ switch (event.type) {
+ case "tool-setOrgName": {
+ const out = event.output;
+ if (out.name) setOrgName(out.name);
+ return false;
+ }
+ case "tool-addCamera": {
+ const out = event.output;
+ if (out.name) addCamera({ name: out.name, location: out.location });
+ return false;
+ }
+ case "tool-addDetectionRule": {
+ const out = event.output;
+ if (out.label) {
+ addDetectionRule({
+ label: out.label,
+ description: out.description,
+ severity: out.severity,
+ });
+ }
+ return false;
+ }
+ case "tool-setAlerts": {
+ const out = event.output;
+ setAlerts({
+ channels: out.channels,
+ severityThreshold: out.severityThreshold,
+ email: out.email,
+ phone: out.phone,
+ });
+ return false;
+ }
+ case "tool-completeOnboarding":
+ return true;
+ default:
+ return false;
+ }
+}
diff --git a/components/onboarding/assembly-sequence.tsx b/components/onboarding/assembly-sequence.tsx
new file mode 100644
index 0000000..d7664d6
--- /dev/null
+++ b/components/onboarding/assembly-sequence.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import {
+ IconBell,
+ IconBuilding,
+ IconCheck,
+ IconDeviceCctv,
+ IconLoader2,
+ IconShieldCheck,
+} from "@tabler/icons-react";
+import { AnimatePresence, motion } from "framer-motion";
+import type { OnboardingToolEvent } from "@/components/onboarding/onboarding-types";
+import { cn } from "@/lib/utils";
+
+function iconFor(type: OnboardingToolEvent["type"]) {
+ switch (type) {
+ case "tool-setOrgName":
+ return IconBuilding;
+ case "tool-addCamera":
+ return IconDeviceCctv;
+ case "tool-addDetectionRule":
+ return IconShieldCheck;
+ case "tool-setAlerts":
+ return IconBell;
+ default:
+ return IconCheck;
+ }
+}
+
+export function AssemblySequence({
+ events,
+ busy,
+ className,
+}: {
+ events: OnboardingToolEvent[];
+ busy: boolean;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ Building your workspace
+
+
+ Argus is translating your description into cameras, rules, and
+ alert settings.
+
+
+
+
+
+
+ {events.map((event) => {
+ const Icon = iconFor(event.type);
+ return (
+
+
+
+
+
+ {event.label}
+
+
+
+ );
+ })}
+
+
+ {events.length === 0 && (
+
+ Waiting for setup actions...
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/onboarding/collapsible-copilot-panel.tsx b/components/onboarding/collapsible-copilot-panel.tsx
new file mode 100644
index 0000000..c796d56
--- /dev/null
+++ b/components/onboarding/collapsible-copilot-panel.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import type { UIMessage } from "@ai-sdk/react";
+import {
+ IconLayoutSidebarRightCollapse,
+ IconMessageCircle,
+} from "@tabler/icons-react";
+import { AnimatePresence, motion } from "framer-motion";
+import { useEffect, useState } from "react";
+import { CopilotPanel } from "@/components/onboarding/copilot-panel";
+import { cn } from "@/lib/utils";
+
+interface CollapsibleCopilotPanelProps {
+ messages: UIMessage[];
+ status: string;
+ input: string;
+ onInputChange: (value: string) => void;
+ onSend: (text: string) => void;
+ visibleToolCallIds?: Set;
+ className?: string;
+}
+
+export function CollapsibleCopilotPanel({
+ messages,
+ status,
+ input,
+ onInputChange,
+ onSend,
+ visibleToolCallIds,
+ className,
+}: CollapsibleCopilotPanelProps) {
+ const [open, setOpen] = useState(true);
+ const [isDesktop, setIsDesktop] = useState(false);
+
+ useEffect(() => {
+ const media = window.matchMedia("(min-width: 1024px)");
+ const update = () => setIsDesktop(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+
+ return (
+
+
+
+ {open ? (
+
+ setOpen(false)}
+ aria-label="Collapse setup copilot"
+ whileTap={{ scale: 0.96 }}
+ className="absolute right-3 top-3 z-10 flex size-8 cursor-pointer items-center justify-center rounded-md text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
+ >
+
+
+
+
+ ) : (
+ setOpen(true)}
+ aria-label="Open setup copilot"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ whileTap={{ scale: 0.96 }}
+ transition={{ duration: 0.16, ease: "easeOut" }}
+ className="flex h-full w-full cursor-pointer items-start justify-center rounded-lg pt-4 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
+ >
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/onboarding/completion.ts b/components/onboarding/completion.ts
new file mode 100644
index 0000000..e7ea953
--- /dev/null
+++ b/components/onboarding/completion.ts
@@ -0,0 +1,19 @@
+import type { DemoSessionState } from "@/lib/demo/session-store";
+
+/**
+ * Whether each onboarding section is "complete", in marker order:
+ * [organization, cameras, detection rules, alerts]. Shared by the review
+ * canvas progress markers and the per-section "done" indicators.
+ */
+export function sectionCompletion(
+ session: DemoSessionState,
+): [boolean, boolean, boolean, boolean] {
+ return [
+ !!session.orgName,
+ session.cameras.length > 0,
+ session.detectionRules.length > 0,
+ session.alertPrefs.channels.length > 1 ||
+ !!session.alertPrefs.email ||
+ !!session.alertPrefs.phone,
+ ];
+}
diff --git a/components/onboarding/composer.tsx b/components/onboarding/composer.tsx
new file mode 100644
index 0000000..f7427f3
--- /dev/null
+++ b/components/onboarding/composer.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { IconArrowUp } from "@tabler/icons-react";
+import { motion } from "framer-motion";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface ComposerProps {
+ input: string;
+ busy: boolean;
+ onInputChange: (value: string) => void;
+ onSubmit: (text: string) => void;
+ /** Accessible label for the send button. */
+ sendLabel: string;
+ /** Footer hint shown beside the send button. */
+ hint?: string;
+ placeholder?: string;
+ /** Tighter min-heights and smaller text for the docked panel composer. */
+ compact?: boolean;
+ className?: string;
+}
+
+/**
+ * Shared composer: card → muted box → textarea (Enter-to-submit) → footer hint
+ * + send button. Used by the onboarding hero and the copilot panel.
+ */
+export function Composer({
+ input,
+ busy,
+ onInputChange,
+ onSubmit,
+ sendLabel,
+ hint = "Cameras, rules, alerts, and launch settings",
+ placeholder = "Describe your space...",
+ compact = false,
+ className,
+}: ComposerProps) {
+ const submit = () => {
+ const text = input.trim();
+ if (!text || busy) return;
+ onSubmit(text);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/components/onboarding/confirm-launch.tsx b/components/onboarding/confirm-launch.tsx
new file mode 100644
index 0000000..fd58e70
--- /dev/null
+++ b/components/onboarding/confirm-launch.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import {
+ IconArrowRight,
+ IconBell,
+ IconDeviceCctv,
+ IconShieldCheck,
+} from "@tabler/icons-react";
+import { motion } from "framer-motion";
+import type React from "react";
+import { Button } from "@/components/ui/button";
+import type { DemoSessionState } from "@/lib/demo/session-store";
+import { cn } from "@/lib/utils";
+
+export function ConfirmLaunch({
+ session,
+ finishing,
+ onBack,
+ onLaunch,
+ className,
+}: {
+ session: DemoSessionState;
+ finishing: boolean;
+ onBack: () => void;
+ onLaunch: () => void;
+ className?: string;
+}) {
+ const alertChannels = session.alertPrefs.channels.join(", ") || "dashboard";
+
+ return (
+
+
+
+
+ Confirm launch
+
+
+ {session.orgName ?? "Your workspace"} is ready.
+
+
+ Argus will open the live dashboard with your session cameras merged
+ into the demo workspace.
+
+
+
+
+
+
+
+
+
+
+
+ Launch summary
+
+
+ Organization: {session.orgName ?? "Unnamed workspace"}
+
+ Cameras:{" "}
+ {session.cameras.length
+ ? session.cameras.map((camera) => camera.camera_name).join(", ")
+ : "none yet"}
+
+
+ Rules:{" "}
+ {session.detectionRules.length
+ ? session.detectionRules.map((rule) => rule.label).join(", ")
+ : "none yet"}
+
+
+ Alerts: {alertChannels}, {session.alertPrefs.severityThreshold}+
+
+
+
+
+
+
+ Back to review
+
+
+
+ Launch workspace
+
+
+
+
+
+
+ );
+}
+
+function SummaryTile({
+ icon: Icon,
+ label,
+ value,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {label}
+
+ {value}
+
+
+ );
+}
diff --git a/components/onboarding/constants.ts b/components/onboarding/constants.ts
new file mode 100644
index 0000000..7e5dec7
--- /dev/null
+++ b/components/onboarding/constants.ts
@@ -0,0 +1,74 @@
+import type {
+ OnboardingToolOutput,
+ OnboardingToolOutputMap,
+ OnboardingToolType,
+} from "@/components/onboarding/onboarding-types";
+import { ONBOARDING_TOOL_TYPE_TUPLE } from "@/components/onboarding/onboarding-types";
+
+/** Runtime set of recognised tool-call types, derived from the shared tuple. */
+export const ONBOARDING_TOOL_TYPES = new Set(
+ ONBOARDING_TOOL_TYPE_TUPLE,
+);
+
+export function isOnboardingToolType(type: string): type is OnboardingToolType {
+ return ONBOARDING_TOOL_TYPES.has(type as OnboardingToolType);
+}
+
+/**
+ * Human-readable summaries of a completed tool call. Typed per tool so each
+ * label receives exactly its own output payload (no weak intersection).
+ */
+type ToolLabelMap = {
+ [K in OnboardingToolType]: (output: OnboardingToolOutputMap[K]) => string;
+};
+
+export const TOOL_LABELS: ToolLabelMap = {
+ "tool-setOrgName": (o) => `Named your workspace "${o.name ?? ""}"`,
+ "tool-addCamera": (o) =>
+ `Added camera: ${o.name ?? "Camera"}${o.location ? ` - ${o.location}` : ""}`,
+ "tool-addDetectionRule": (o) => `Enabled rule: ${o.label ?? "Detection"}`,
+ "tool-setAlerts": (o) =>
+ `Set alerts: ${(o.channels ?? []).join(", ") || "dashboard"}`,
+ "tool-completeOnboarding": () => "Finished setup",
+};
+
+const DEFAULT_TOOL_LABEL = "Updated setup";
+
+/**
+ * Resolve the human-readable label for a tool call given its runtime `type` and
+ * raw output. Falls back to a generic label for unrecognised types. Centralises
+ * the type → payload narrowing the streamed (loosely-typed) output can't express.
+ */
+export function toolLabel(
+ type: string,
+ output: OnboardingToolOutput | undefined,
+): string {
+ if (!isOnboardingToolType(type)) return DEFAULT_TOOL_LABEL;
+ const label = TOOL_LABELS[type] as (o: OnboardingToolOutput) => string;
+ return label(output ?? {});
+}
+
+/** Short titles for the tool header in the copilot transcript. */
+export const TOOL_TITLES: Record = {
+ "tool-setOrgName": "Set workspace name",
+ "tool-addCamera": "Add camera",
+ "tool-addDetectionRule": "Add detection rule",
+ "tool-setAlerts": "Configure alerts",
+ "tool-completeOnboarding": "Complete onboarding",
+};
+
+/**
+ * Shared class for the filled (muted-background) text inputs used across the
+ * onboarding sections and editable lists. Kept here so the single style lives in
+ * one place; the project's `Input`/`Textarea` use a bordered/transparent style
+ * that doesn't match this filled treatment, so this is intentionally separate.
+ */
+export const ONBOARDING_INPUT_CLASS =
+ "w-full rounded-md bg-[var(--muted)] px-3 py-2 text-sm text-[var(--foreground)] outline-none ring-1 ring-black/5 transition-shadow placeholder:text-[var(--muted-foreground)] focus:ring-[var(--ring)] dark:ring-white/10";
+
+/** Example prompts offered on the hero and empty copilot panel. */
+export const SUGGESTIONS = [
+ "3-story office - watch the lobby and parking, flag weapons and medical emergencies",
+ "Retail store, cover the entrance and stockroom, alert me about theft and loitering",
+ "Warehouse with a loading dock and perimeter - detect unauthorized access and safety issues",
+];
diff --git a/components/onboarding/copilot-panel.tsx b/components/onboarding/copilot-panel.tsx
new file mode 100644
index 0000000..02e1bd8
--- /dev/null
+++ b/components/onboarding/copilot-panel.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import type { UIMessage } from "@ai-sdk/react";
+import { AnimatePresence, motion } from "framer-motion";
+import {
+ Conversation,
+ ConversationContent,
+} from "@/components/ai-elements/conversation";
+import { Loader } from "@/components/ai-elements/loader";
+import { Message, MessageContent } from "@/components/ai-elements/message";
+import { Response } from "@/components/ai-elements/response";
+import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool";
+import { Composer } from "@/components/onboarding/composer";
+import { TOOL_TITLES, toolLabel } from "@/components/onboarding/constants";
+import type {
+ TextPart,
+ ToolPart,
+} from "@/components/onboarding/onboarding-types";
+import { isBusy } from "@/components/onboarding/status";
+import { SuggestionCards } from "@/components/onboarding/suggestion-cards";
+import { cn } from "@/lib/utils";
+
+interface CopilotPanelProps {
+ messages: UIMessage[];
+ status: string;
+ input: string;
+ onInputChange: (value: string) => void;
+ onSend: (text: string) => void;
+ visibleToolCallIds?: Set;
+ className?: string;
+}
+
+export function CopilotPanel({
+ messages,
+ status,
+ input,
+ onInputChange,
+ onSend,
+ visibleToolCallIds,
+ className,
+}: CopilotPanelProps) {
+ const busy = isBusy(status);
+
+ const submitText = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed || busy) return;
+ onSend(trimmed);
+ };
+
+ return (
+
+
+
+ {messages.length === 0 && (
+
+
+
+ Argus setup
+
+
+ Describe your space.
+
+
+ Tell Argus what kind of site you run, which areas need
+ cameras, and what the AI should flag.
+
+
+
+
+
+
+ )}
+
+
+ {messages.map((message) => (
+
+
+
+ {message.parts.map((part) => {
+ if (part.type === "text") {
+ const text = (part as TextPart).text;
+ return (
+
+ {text}
+
+ );
+ }
+
+ if (
+ typeof part.type === "string" &&
+ part.type.startsWith("tool-")
+ ) {
+ const toolPart = part as ToolPart;
+ const callId = toolPart.toolCallId;
+ if (
+ visibleToolCallIds &&
+ callId &&
+ !visibleToolCallIds.has(callId)
+ ) {
+ return null;
+ }
+
+ const state = toolPart.state ?? "input-available";
+ const label = toolLabel(part.type, toolPart.output);
+ const title =
+ TOOL_TITLES[part.type] ??
+ part.type.replace("tool-", "");
+
+ return (
+
+
+ {state === "output-available" && (
+
+
+ {label}
+
+
+ )}
+
+ );
+ }
+
+ return null;
+ })}
+
+
+
+ ))}
+
+
+
+ {busy && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {messages.length > 0 && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/onboarding/editable-list.tsx b/components/onboarding/editable-list.tsx
new file mode 100644
index 0000000..ebcf288
--- /dev/null
+++ b/components/onboarding/editable-list.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { IconPlus, IconX } from "@tabler/icons-react";
+import { AnimatePresence, motion } from "framer-motion";
+import type React from "react";
+import { useState } from "react";
+import { ONBOARDING_INPUT_CLASS } from "@/components/onboarding/constants";
+import { cn } from "@/lib/utils";
+
+interface EditableListItem {
+ id: string;
+}
+
+interface EditableListProps {
+ items: T[];
+ /** Row content rendered inside the removable row. */
+ renderItem: (item: T) => React.ReactNode;
+ /** Add an item from the (trimmed, non-empty) input value. */
+ onAdd: (value: string) => void;
+ onRemove: (id: string) => void;
+ /** Accessible label for a row's remove button, given the item. */
+ removeLabel: (item: T) => string;
+ placeholder: string;
+ className?: string;
+}
+
+/**
+ * Generic editable list: an animated stack of removable rows plus an input and
+ * add button. Used for the cameras and detection-rule sections.
+ */
+export function EditableList({
+ items,
+ renderItem,
+ onAdd,
+ onRemove,
+ removeLabel,
+ placeholder,
+ className,
+}: EditableListProps) {
+ const [value, setValue] = useState("");
+
+ const add = () => {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ onAdd(trimmed);
+ setValue("");
+ };
+
+ return (
+
+
+ {items.map((item) => (
+
+ {renderItem(item)}
+ onRemove(item.id)}
+ aria-label={removeLabel(item)}
+ whileTap={{ scale: 0.94 }}
+ className="shrink-0 text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
+ >
+
+
+
+ ))}
+
+
+
setValue(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && add()}
+ placeholder={placeholder}
+ className={ONBOARDING_INPUT_CLASS}
+ />
+
+
+
+ );
+}
+
+function AddButton({ onClick }: { onClick: () => void }) {
+ return (
+
+
+
+ );
+}
diff --git a/components/onboarding/onboarding-hero.tsx b/components/onboarding/onboarding-hero.tsx
new file mode 100644
index 0000000..838417c
--- /dev/null
+++ b/components/onboarding/onboarding-hero.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { IconArrowRight } from "@tabler/icons-react";
+import { motion } from "framer-motion";
+import { Composer } from "@/components/onboarding/composer";
+import { isBusy } from "@/components/onboarding/status";
+import { SuggestionCards } from "@/components/onboarding/suggestion-cards";
+import { cn } from "@/lib/utils";
+
+interface OnboardingHeroProps {
+ input: string;
+ status: string;
+ onInputChange: (value: string) => void;
+ onSubmit: (text: string) => void;
+ onManual: () => void;
+ className?: string;
+}
+
+export function OnboardingHero({
+ input,
+ status,
+ onInputChange,
+ onSubmit,
+ onManual,
+ className,
+}: OnboardingHeroProps) {
+ const busy = isBusy(status);
+
+ return (
+
+
+
+
+
+ Argus setup
+
+
+ Describe your space.
+
+
+ Tell Argus what kind of site you run, which areas need cameras, and
+ what the AI should flag.
+
+
+
+
+
+
+
+
+ or set it up manually
+
+
+
+
+ );
+}
diff --git a/components/onboarding/onboarding-sections.tsx b/components/onboarding/onboarding-sections.tsx
new file mode 100644
index 0000000..e49a89a
--- /dev/null
+++ b/components/onboarding/onboarding-sections.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import {
+ IconBell,
+ IconBuilding,
+ IconCheck,
+ IconDeviceCctv,
+ IconShieldCheck,
+} from "@tabler/icons-react";
+import { motion } from "framer-motion";
+import type React from "react";
+import { ONBOARDING_INPUT_CLASS } from "@/components/onboarding/constants";
+import { EditableList } from "@/components/onboarding/editable-list";
+import type { DemoSessionState, Severity } from "@/lib/demo/session-store";
+import {
+ addCamera,
+ addDetectionRule,
+ removeCamera,
+ removeDetectionRule,
+ setAlerts,
+ setOrgName,
+} from "@/lib/demo/session-store";
+
+type IconComponent = React.ComponentType<{ className?: string }>;
+type Tone = "slate" | "blue" | "emerald" | "amber" | "rose";
+
+const TONE_CLASS: Record = {
+ slate: "bg-slate-100 text-slate-700 dark:bg-slate-500/15 dark:text-slate-300",
+ blue: "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-300",
+ emerald:
+ "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300",
+ amber: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300",
+ rose: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300",
+};
+
+const SEVERITY_DOT: Record = {
+ High: "bg-red-400",
+ Medium: "bg-amber-400",
+ Minor: "bg-[var(--muted-foreground)]",
+};
+
+const CHANNELS: Array<{ key: "dashboard" | "email" | "sms"; label: string }> = [
+ { key: "dashboard", label: "Dashboard" },
+ { key: "email", label: "Email" },
+ { key: "sms", label: "SMS" },
+];
+
+function Section({
+ icon: Icon,
+ title,
+ description,
+ done,
+ tone,
+ children,
+}: {
+ icon: IconComponent;
+ title: string;
+ description: string;
+ done: boolean;
+ tone: Tone;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {done ? (
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {children}
+
+ );
+}
+
+export function OrgSection({
+ name,
+ done,
+}: {
+ name: string | null;
+ done: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function CamerasSection({
+ cameras,
+ done,
+}: {
+ cameras: DemoSessionState["cameras"];
+ done: boolean;
+}) {
+ return (
+
+ addCamera({ name })}
+ onRemove={removeCamera}
+ removeLabel={(c) => `Remove ${c.camera_name}`}
+ placeholder="Add a camera (e.g. Lobby)"
+ renderItem={(c) => (
+
+
+ {c.camera_name}
+
+ )}
+ />
+
+ );
+}
+
+export function RulesSection({
+ rules,
+ done,
+}: {
+ rules: DemoSessionState["detectionRules"];
+ done: boolean;
+}) {
+ return (
+
+ addDetectionRule({ label })}
+ onRemove={removeDetectionRule}
+ removeLabel={(r) => `Remove ${r.label}`}
+ placeholder="Add a rule (e.g. Weapons)"
+ renderItem={(r) => (
+
+
+ {r.label}
+ {r.description && (
+
+ - {r.description}
+
+ )}
+
+ )}
+ />
+
+ );
+}
+
+export function AlertsSection({
+ prefs,
+ done,
+}: {
+ prefs: DemoSessionState["alertPrefs"];
+ done: boolean;
+}) {
+ const toggleChannel = (key: "dashboard" | "email" | "sms") => {
+ const has = prefs.channels.includes(key);
+ const channels = has
+ ? prefs.channels.filter((c) => c !== key)
+ : [...prefs.channels, key];
+ setAlerts({ channels });
+ };
+ const severities: Severity[] = ["Minor", "Medium", "High"];
+
+ return (
+
+
+
+
+ Channels
+
+
+ {CHANNELS.map((ch) => {
+ const active = prefs.channels.includes(ch.key);
+ return (
+ toggleChannel(ch.key)}
+ whileTap={{ scale: 0.97 }}
+ className={`cursor-pointer rounded-md border px-3 py-1.5 text-xs transition-colors ${
+ active
+ ? "border-transparent bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300"
+ : "border-transparent bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
+ }`}
+ >
+ {ch.label}
+
+ );
+ })}
+
+
+
+
+ Minimum severity
+
+
+ {severities.map((sev) => {
+ const active = prefs.severityThreshold === sev;
+ return (
+ setAlerts({ severityThreshold: sev })}
+ whileTap={{ scale: 0.97 }}
+ className={`cursor-pointer rounded-md border px-3 py-1.5 text-xs transition-colors ${
+ active
+ ? "border-transparent bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300"
+ : "border-transparent bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
+ }`}
+ >
+ {sev}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/components/onboarding/onboarding-theme-scope.tsx b/components/onboarding/onboarding-theme-scope.tsx
new file mode 100644
index 0000000..d0f867a
--- /dev/null
+++ b/components/onboarding/onboarding-theme-scope.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import type React from "react";
+import { useEffect } from "react";
+
+/** next-themes' default localStorage key for the persisted theme choice. */
+const THEME_STORAGE_KEY = "theme";
+
+export function OnboardingThemeScope({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { setTheme } = useTheme();
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ if (!window.localStorage.getItem(THEME_STORAGE_KEY)) {
+ setTheme("light");
+ }
+ }, [setTheme]);
+
+ return <>{children}>;
+}
diff --git a/components/onboarding/onboarding-types.ts b/components/onboarding/onboarding-types.ts
new file mode 100644
index 0000000..a5fda3d
--- /dev/null
+++ b/components/onboarding/onboarding-types.ts
@@ -0,0 +1,99 @@
+import type { Severity } from "@/lib/demo/session-store";
+
+export type OnboardingPhase = "hero" | "assembling" | "review" | "confirm";
+
+/**
+ * Single source of truth for the onboarding tool-call types. The runtime set
+ * (`ONBOARDING_TOOL_TYPES` in constants.ts) is derived from this tuple, and the
+ * `OnboardingToolType` union below is derived from it too — so the literal list
+ * is maintained in exactly one place.
+ */
+export const ONBOARDING_TOOL_TYPE_TUPLE = [
+ "tool-setOrgName",
+ "tool-addCamera",
+ "tool-addDetectionRule",
+ "tool-setAlerts",
+ "tool-completeOnboarding",
+] as const;
+
+export type OnboardingToolType = (typeof ONBOARDING_TOOL_TYPE_TUPLE)[number];
+
+/**
+ * Tool output payloads, one interface per tool type. These mirror the input
+ * shapes consumed by the session-store mutators.
+ */
+export interface SetOrgNameOutput {
+ name?: string;
+}
+
+export interface AddCameraOutput {
+ name?: string;
+ location?: string;
+}
+
+export interface AddDetectionRuleOutput {
+ label?: string;
+ description?: string;
+ severity?: Severity;
+}
+
+export interface SetAlertsOutput {
+ channels?: Array<"dashboard" | "email" | "sms">;
+ severityThreshold?: Severity;
+ email?: string;
+ phone?: string;
+}
+
+export interface CompleteOnboardingOutput {
+ confirm?: boolean;
+}
+
+/**
+ * Maps each tool type to its output payload. The discriminated unions below are
+ * derived from this map so the literal type → payload pairing lives in one place
+ * and narrowing on `type` gives the exact payload (no weak intersection, so no
+ * spurious `o?.field` optional chaining is forced on consumers).
+ */
+export interface OnboardingToolOutputMap {
+ "tool-setOrgName": SetOrgNameOutput;
+ "tool-addCamera": AddCameraOutput;
+ "tool-addDetectionRule": AddDetectionRuleOutput;
+ "tool-setAlerts": SetAlertsOutput;
+ "tool-completeOnboarding": CompleteOnboardingOutput;
+}
+
+/** Union of every tool output payload, for code that handles a raw payload. */
+export type OnboardingToolOutput =
+ OnboardingToolOutputMap[keyof OnboardingToolOutputMap];
+
+/**
+ * A discovered tool call: a discriminated union over `type` where each member
+ * carries the matching output payload, so `switch (event.type)` narrows
+ * `event.output` to the exact shape.
+ */
+export type OnboardingToolEvent = {
+ [K in OnboardingToolType]: {
+ id: string;
+ type: K;
+ output: OnboardingToolOutputMap[K];
+ label: string;
+ };
+}[OnboardingToolType];
+
+/** A streamed text part of an assistant message. */
+export interface TextPart {
+ type: "text";
+ text: string;
+}
+
+/** A streamed tool-call part of an assistant message. */
+export interface ToolPart {
+ type: string;
+ state?:
+ | "input-streaming"
+ | "input-available"
+ | "output-available"
+ | "output-error";
+ toolCallId?: string;
+ output?: OnboardingToolOutput;
+}
diff --git a/components/onboarding/onboarding-wizard.tsx b/components/onboarding/onboarding-wizard.tsx
new file mode 100644
index 0000000..b5963bc
--- /dev/null
+++ b/components/onboarding/onboarding-wizard.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useChat } from "@ai-sdk/react";
+import { IconCheck } from "@tabler/icons-react";
+import { DefaultChatTransport, generateId } from "ai";
+import { AnimatePresence, motion } from "framer-motion";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ModeToggle } from "@/components/mode-toggle";
+import { applyToolEvent } from "@/components/onboarding/apply-tool-event";
+import { AssemblySequence } from "@/components/onboarding/assembly-sequence";
+import { CollapsibleCopilotPanel } from "@/components/onboarding/collapsible-copilot-panel";
+import { ConfirmLaunch } from "@/components/onboarding/confirm-launch";
+import {
+ isOnboardingToolType,
+ toolLabel,
+} from "@/components/onboarding/constants";
+import { OnboardingHero } from "@/components/onboarding/onboarding-hero";
+import type {
+ OnboardingPhase,
+ OnboardingToolEvent,
+ ToolPart,
+} from "@/components/onboarding/onboarding-types";
+import { ReviewCanvas } from "@/components/onboarding/review-canvas";
+import { isBusy } from "@/components/onboarding/status";
+import { useDemoSession } from "@/hooks/use-demo-session";
+import { completeOnboarding, reset } from "@/lib/demo/session-store";
+
+/** AI model that drives the onboarding copilot. */
+const ONBOARDING_MODEL = "onboarding-guide";
+
+/** Delay before redirecting to the live dashboard after launch. */
+const LAUNCH_REDIRECT_MS = 1600;
+
+/** Stagger for revealing assembly events: first one is quick, the rest pace out. */
+const FIRST_REVEAL_MS = 300;
+const NEXT_REVEAL_MS = 620;
+
+/** Settle delay before advancing from "assembling" to "review". */
+const ASSEMBLY_SETTLE_MS = 850;
+
+export function OnboardingWizard() {
+ const router = useRouter();
+ const session = useDemoSession();
+ const [phase, setPhase] = useState("hero");
+ const [input, setInput] = useState("");
+ const [finishing, setFinishing] = useState(false);
+ const [pendingEvents, setPendingEvents] = useState([]);
+ const [revealedEvents, setRevealedEvents] = useState(
+ [],
+ );
+ const [visibleToolCallIds, setVisibleToolCallIds] = useState>(
+ () => new Set(),
+ );
+ const [assemblyObservedTurn, setAssemblyObservedTurn] = useState(false);
+
+ const [chatId] = useState(() => generateId());
+ const sessionRef = useRef(session);
+ const discoveredRef = useRef>(new Set());
+ const appliedRef = useRef>(new Set());
+ const assemblyStartMessageCountRef = useRef(0);
+
+ useEffect(() => {
+ sessionRef.current = session;
+ }, [session]);
+
+ const transport = useMemo(
+ () =>
+ new DefaultChatTransport({
+ api: "/api/chat",
+ body: () => ({
+ chatId,
+ onboarding: true,
+ model: ONBOARDING_MODEL,
+ sessionContext: {
+ hasOrgName: !!sessionRef.current.orgName,
+ cameraNames: sessionRef.current.cameras.map((c) => c.camera_name),
+ ruleLabels: sessionRef.current.detectionRules.map((r) => r.label),
+ },
+ }),
+ }),
+ [chatId],
+ );
+
+ const { messages, sendMessage, status } = useChat({ id: chatId, transport });
+ const busy = isBusy(status);
+
+ const handleFinish = useCallback(() => {
+ if (finishing) return;
+ completeOnboarding(true);
+ setFinishing(true);
+ window.setTimeout(() => router.push("/watch"), LAUNCH_REDIRECT_MS);
+ }, [finishing, router]);
+
+ const applyEvent = useCallback((event: OnboardingToolEvent) => {
+ if (appliedRef.current.has(event.id)) return;
+ appliedRef.current.add(event.id);
+ if (applyToolEvent(event)) setPhase("confirm");
+ }, []);
+
+ useEffect(() => {
+ if (
+ phase === "assembling" &&
+ messages.length > assemblyStartMessageCountRef.current
+ ) {
+ setAssemblyObservedTurn(true);
+ }
+
+ const discovered: OnboardingToolEvent[] = [];
+ for (const message of messages) {
+ for (const part of message.parts) {
+ const type = part.type as string;
+ if (!isOnboardingToolType(type)) continue;
+ const p = part as ToolPart;
+ if (p.state !== "output-available") continue;
+ const callId = p.toolCallId ?? `${message.id}:${type}`;
+ if (discoveredRef.current.has(callId)) continue;
+ discoveredRef.current.add(callId);
+
+ const output = p.output ?? {};
+ // `type` is validated by isOnboardingToolType above; the streamed output
+ // is loosely typed, so assert the discriminated-union member here.
+ discovered.push({
+ id: callId,
+ type,
+ output,
+ label: toolLabel(type, output),
+ } as OnboardingToolEvent);
+ }
+ }
+
+ if (!discovered.length) return;
+ setAssemblyObservedTurn(true);
+
+ if (phase === "assembling") {
+ setPendingEvents((current) => [...current, ...discovered]);
+ return;
+ }
+
+ setVisibleToolCallIds((current) => {
+ const next = new Set(current);
+ for (const event of discovered) next.add(event.id);
+ return next;
+ });
+ for (const event of discovered) applyEvent(event);
+ }, [applyEvent, messages, phase]);
+
+ useEffect(() => {
+ if (phase !== "assembling" || pendingEvents.length === 0) return;
+
+ const timeout = window.setTimeout(
+ () => {
+ const [nextEvent] = pendingEvents;
+ setPendingEvents((current) => current.slice(1));
+ setRevealedEvents((current) => [...current, nextEvent]);
+ setVisibleToolCallIds((current) => {
+ const next = new Set(current);
+ next.add(nextEvent.id);
+ return next;
+ });
+ applyEvent(nextEvent);
+ },
+ revealedEvents.length === 0 ? FIRST_REVEAL_MS : NEXT_REVEAL_MS,
+ );
+
+ return () => window.clearTimeout(timeout);
+ }, [applyEvent, pendingEvents, phase, revealedEvents.length]);
+
+ useEffect(() => {
+ if (phase !== "assembling" || busy || pendingEvents.length > 0) return;
+ if (!assemblyObservedTurn) return;
+
+ const timeout = window.setTimeout(
+ () => setPhase("review"),
+ ASSEMBLY_SETTLE_MS,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [assemblyObservedTurn, busy, pendingEvents.length, phase]);
+
+ const handleHeroSubmit = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed || busy) return;
+ assemblyStartMessageCountRef.current = messages.length;
+ setAssemblyObservedTurn(false);
+ setPendingEvents([]);
+ setRevealedEvents([]);
+ setPhase("assembling");
+ sendMessage({ text: trimmed });
+ setInput("");
+ };
+
+ const handleCopilotSend = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed || busy) return;
+ sendMessage({ text: trimmed });
+ setInput("");
+ };
+
+ const handleManual = () => {
+ reset();
+ setPendingEvents([]);
+ setRevealedEvents([]);
+ setPhase("review");
+ };
+
+ return (
+
+ {(phase === "hero" || phase === "assembling") && (
+
+
+
+ )}
+ {phase !== "hero" && phase !== "assembling" && (
+
+ )}
+
+
+ {phase === "hero" && (
+
+
+
+ )}
+
+ {phase === "assembling" && (
+
+
+
+ )}
+
+ {phase === "review" && (
+
+
+ setPhase("confirm")}
+ />
+
+
+
+ )}
+
+ {phase === "confirm" && (
+
+ setPhase("review")}
+ onLaunch={handleFinish}
+ />
+
+ )}
+
+
+
+ {finishing && (
+
+
+
+
+
+ {session.orgName ?? "Your workspace"} is ready
+
+
+ Taking you to the live dashboard...
+
+
+ )}
+
+
+ );
+}
diff --git a/components/onboarding/review-canvas.tsx b/components/onboarding/review-canvas.tsx
new file mode 100644
index 0000000..1bc26cb
--- /dev/null
+++ b/components/onboarding/review-canvas.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import {
+ IconBell,
+ IconBuilding,
+ IconDeviceCctv,
+ IconShieldCheck,
+} from "@tabler/icons-react";
+import { motion } from "framer-motion";
+import { sectionCompletion } from "@/components/onboarding/completion";
+import {
+ AlertsSection,
+ CamerasSection,
+ OrgSection,
+ RulesSection,
+} from "@/components/onboarding/onboarding-sections";
+import { Button } from "@/components/ui/button";
+import type { DemoSessionState } from "@/lib/demo/session-store";
+import { cn } from "@/lib/utils";
+
+const MARKERS = [
+ {
+ label: "Organization",
+ icon: IconBuilding,
+ barClass: "bg-slate-500",
+ iconClass: "text-slate-600 dark:text-slate-300",
+ },
+ {
+ label: "Cameras",
+ icon: IconDeviceCctv,
+ barClass: "bg-blue-500",
+ iconClass: "text-blue-600 dark:text-blue-300",
+ },
+ {
+ label: "Detection",
+ icon: IconShieldCheck,
+ barClass: "bg-emerald-500",
+ iconClass: "text-emerald-600 dark:text-emerald-300",
+ },
+ {
+ label: "Alerts",
+ icon: IconBell,
+ barClass: "bg-amber-500",
+ iconClass: "text-amber-600 dark:text-amber-300",
+ },
+];
+
+export function ReviewCanvas({
+ session,
+ onConfirm,
+ className,
+}: {
+ session: DemoSessionState;
+ onConfirm: () => void;
+ className?: string;
+}) {
+ const done = sectionCompletion(session);
+ const completed = done.filter(Boolean).length;
+
+ return (
+
+
+
+
+
+ Review your workspace
+
+
+ Everything is editable before launch.
+
+
+
+ {completed}/{done.length} complete
+
+
+
+
+ {MARKERS.map((marker, index) => (
+
+
+
+
+
+ {marker.label}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Continue to launch
+
+
+
+
+ );
+}
diff --git a/components/onboarding/status.ts b/components/onboarding/status.ts
new file mode 100644
index 0000000..a6b83c7
--- /dev/null
+++ b/components/onboarding/status.ts
@@ -0,0 +1,4 @@
+/** A chat is "busy" while a request is in flight or a response is streaming. */
+export function isBusy(status: string): boolean {
+ return status === "streaming" || status === "submitted";
+}
diff --git a/components/onboarding/suggestion-cards.tsx b/components/onboarding/suggestion-cards.tsx
new file mode 100644
index 0000000..fe12cb2
--- /dev/null
+++ b/components/onboarding/suggestion-cards.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { SUGGESTIONS } from "@/components/onboarding/constants";
+import { cn } from "@/lib/utils";
+
+interface SuggestionCardsProps {
+ onSelect: (suggestion: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+/**
+ * Grid of example prompts. Selecting one submits it. Call sites control the
+ * grid columns via `className` (e.g. `sm:grid-cols-3` on the hero).
+ */
+export function SuggestionCards({
+ onSelect,
+ disabled = false,
+ className,
+}: SuggestionCardsProps) {
+ return (
+
+ {SUGGESTIONS.map((suggestion) => (
+ onSelect(suggestion)}
+ disabled={disabled}
+ whileHover={{ y: -2 }}
+ whileTap={{ scale: 0.99 }}
+ transition={{ duration: 0.16, ease: "easeOut" }}
+ className="cursor-pointer rounded-lg bg-card px-3.5 py-3 text-left text-muted-foreground text-xs leading-5 shadow-sm ring-1 ring-black/5 transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 dark:ring-white/10"
+ >
+ {suggestion}
+
+ ))}
+
+ );
+}
diff --git a/components/page-container.tsx b/components/page-container.tsx
new file mode 100644
index 0000000..c5a7593
--- /dev/null
+++ b/components/page-container.tsx
@@ -0,0 +1,26 @@
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface PageContainerProps extends React.HTMLAttributes {
+ bleed?: boolean;
+}
+
+export function PageContainer({
+ className,
+ bleed = false,
+ children,
+ ...props
+}: PageContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/page-header.tsx b/components/page-header.tsx
new file mode 100644
index 0000000..33c3974
--- /dev/null
+++ b/components/page-header.tsx
@@ -0,0 +1,39 @@
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface PageHeaderProps {
+ title: React.ReactNode;
+ description?: string;
+ actions?: React.ReactNode;
+ className?: string;
+}
+
+export function PageHeader({
+ title,
+ description,
+ actions,
+ className,
+}: PageHeaderProps) {
+ return (
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {actions && (
+
{actions}
+ )}
+
+ );
+}
diff --git a/components/site-footer.tsx b/components/site-footer.tsx
index a4fb290..a8d9281 100644
--- a/components/site-footer.tsx
+++ b/components/site-footer.tsx
@@ -1,33 +1,75 @@
-"use client";
+import { cn } from "@/lib/utils";
-export function SiteFooter() {
+const contributors = [
+ {
+ n: "01",
+ name: "Carson Spriggs-Audet",
+ links: [
+ { label: "LinkedIn", href: "https://www.linkedin.com/in/carsonspriggs" },
+ { label: "GitHub", href: "https://github.com/carsonSgit" },
+ ],
+ },
+ {
+ n: "02",
+ name: "Marcus Lee",
+ links: [
+ { label: "LinkedIn", href: "https://www.linkedin.com/in/marcus-m-lee/" },
+ { label: "GitHub", href: "https://github.com/godpuffin" },
+ ],
+ },
+];
+
+export function SiteFooter({ className }: { className?: string }) {
return (
-
-
-
-
ARGUS
-
-
-
Built by
-
Carson Spriggs-Audet
-
+