+3
-1
@@ -9,6 +9,7 @@ import {
|
|||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import PathResolver from "./pages/path-resolver";
|
import PathResolver from "./pages/path-resolver";
|
||||||
|
import { SettingsPage } from "./pages/settings-page";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ function AuthenticatedApp() {
|
|||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="*" element={<PathResolver />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/*" element={<PathResolver />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -1,29 +1,51 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { LogOut } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Small } from "@/components/ui/typography";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
Select,
|
import { Separator } from "@/components/ui/separator";
|
||||||
SelectContent,
|
import { useNetworks } from "@/hooks/use-networks";
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
|
||||||
import { WindowControls } from "@/components/window-controls";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { apiClient } from "@/api/client";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import type { Network } from "@/api/types";
|
||||||
|
|
||||||
|
function NetworkRow({
|
||||||
|
network,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
network: Network;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const initials = network.name.slice(0, 2).toUpperCase();
|
||||||
|
const memberCount = network.humans.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{network.name}</p>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Users className="size-3" />
|
||||||
|
<Small className="text-muted-foreground">
|
||||||
|
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||||
|
</Small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NetworkSelector() {
|
export function NetworkSelector() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const signOut = useAuthStore((s) => s.signOut);
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
|
|
||||||
const { data, isPending, error } = useQuery({
|
const { data, isPending, error } = useNetworks();
|
||||||
queryKey: ["networks"],
|
|
||||||
queryFn: () => apiClient.listNetworks(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <Progress />;
|
return <Progress />;
|
||||||
@@ -31,7 +53,7 @@ export function NetworkSelector() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-destructive text-sm">Failed to load networks</p>
|
<p className="text-destructive text-sm">Failed to load networks</p>
|
||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,9 +62,10 @@ export function NetworkSelector() {
|
|||||||
|
|
||||||
if (data?.length === 0) {
|
if (data?.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center max-w-sm mx-auto">
|
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
You don't have access to any networks yet. Please email us to get started.
|
You don't have access to any networks yet. Please email us to get
|
||||||
|
started.
|
||||||
</p>
|
</p>
|
||||||
<a href="mailto:[email protected]" className="text-primary underline">
|
<a href="mailto:[email protected]" className="text-primary underline">
|
||||||
team@flowylabs.ai
|
team@flowylabs.ai
|
||||||
@@ -52,35 +75,18 @@ export function NetworkSelector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<ScrollArea className="h-full">
|
||||||
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
<div className="py-1">
|
||||||
<WindowControls />
|
{data?.map((network, index) => (
|
||||||
<div className="flex-1" />
|
<div key={network.id}>
|
||||||
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
|
<NetworkRow
|
||||||
<Button
|
network={network}
|
||||||
variant="ghost"
|
onClick={() => navigate(`/${network.id}`)}
|
||||||
size="sm"
|
/>
|
||||||
className="no-drag text-muted-foreground"
|
{index < data.length - 1 && <Separator className="mx-4" />}
|
||||||
onClick={signOut}
|
</div>
|
||||||
>
|
))}
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
<div className="flex flex-1 items-center justify-center p-4">
|
|
||||||
<Select onValueChange={(value) => navigate(`/${value}`)}>
|
|
||||||
<SelectTrigger className="no-drag w-64">
|
|
||||||
<SelectValue placeholder="Select a network" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{data?.map((network) => (
|
|
||||||
<SelectItem key={network.id} value={network.id}>
|
|
||||||
{network.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
|
||||||
|
export function useNetworks() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["networks"],
|
||||||
|
queryFn: () => apiClient.listNetworks(),
|
||||||
|
});
|
||||||
|
}
|
||||||
+146
-12
@@ -1,12 +1,132 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Home, Settings } from "lucide-react";
|
||||||
import { NetworkSelector } from "@/features/network-selector";
|
import { NetworkSelector } from "@/features/network-selector";
|
||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
|
import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
|
||||||
|
import { WindowControls } from "@/components/window-controls";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Muted } from "@/components/ui/typography";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useNetworks } from "@/hooks/use-networks";
|
||||||
|
|
||||||
function parsePathSegments(path: string): string[] {
|
function parsePathSegments(path: string): string[] {
|
||||||
return path.split("/").filter(Boolean);
|
return path.split("/").filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
||||||
|
const { data: networks } = useNetworks();
|
||||||
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
|
const name = network?.name ?? networkId;
|
||||||
|
const initials = name.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar({ segments }: { segments: string[] }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const networkId = segments[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<WindowControls />
|
||||||
|
|
||||||
|
<Breadcrumb className="no-drag">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<BreadcrumbPage className="flex items-center gap-1">
|
||||||
|
<Home className="size-3.5" />
|
||||||
|
Networks
|
||||||
|
</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink
|
||||||
|
className="flex cursor-pointer items-center gap-1"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
<Home className="size-3.5" />
|
||||||
|
Networks
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
|
||||||
|
{networkId && (
|
||||||
|
<span className="contents">
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{segments.length === 1 ? (
|
||||||
|
<BreadcrumbPage>
|
||||||
|
<NetworkBreadcrumbContent networkId={networkId} />
|
||||||
|
</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => navigate(`/${networkId}`)}
|
||||||
|
>
|
||||||
|
<NetworkBreadcrumbContent networkId={networkId} />
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segments.slice(1).map((segment, index) => {
|
||||||
|
const isLast = index === segments.length - 2;
|
||||||
|
const path = `/${segments.slice(0, index + 2).join("/")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={path} className="contents">
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{isLast ? (
|
||||||
|
<BreadcrumbPage>{segment}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => navigate(path)}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="no-drag text-muted-foreground"
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
>
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL structure:
|
* URL structure:
|
||||||
* / → network selector
|
* / → network selector
|
||||||
@@ -16,18 +136,32 @@ function parsePathSegments(path: string): string[] {
|
|||||||
export default function PathResolver() {
|
export default function PathResolver() {
|
||||||
const segments = parsePathSegments(useLocation().pathname);
|
const segments = parsePathSegments(useLocation().pathname);
|
||||||
|
|
||||||
// No segments → show network selector
|
const content = (() => {
|
||||||
if (segments.length === 0) {
|
// No segments → show network selector
|
||||||
return <NetworkSelector />;
|
if (segments.length === 0) {
|
||||||
}
|
return <NetworkSelector />;
|
||||||
|
}
|
||||||
|
|
||||||
const [networkId, ...particleSegments] = segments;
|
const [networkId, ...particleSegments] = segments;
|
||||||
|
|
||||||
// /:networkId with no particle segments → root particle list
|
// /:networkId with no particle segments → root particle list
|
||||||
if (particleSegments.length === 0) {
|
if (particleSegments.length === 0) {
|
||||||
return <ParticleListView networkId={networkId} particleSegments={[]} />;
|
return <ParticleListView networkId={networkId} particleSegments={[]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /:networkId/:p1/:p2/... → resolve and render the container particle
|
// /:networkId/:p1/:p2/... → resolve and render the container particle
|
||||||
return <ParticleViewResolver networkId={networkId} particleSegments={particleSegments} />;
|
return (
|
||||||
|
<ParticleViewResolver
|
||||||
|
networkId={networkId}
|
||||||
|
particleSegments={particleSegments}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<TopBar segments={segments} />
|
||||||
|
<div className="flex-1 overflow-hidden">{content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ChevronRight, LogOut, User, Info, Shield } from "lucide-react";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { WindowControls } from "@/components/window-controls";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Muted } from "@/components/ui/typography";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
interface SettingsRowProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
onClick,
|
||||||
|
destructive,
|
||||||
|
}: SettingsRowProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent ${destructive ? "text-destructive" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium">{label}</span>
|
||||||
|
{detail && <Muted className="text-xs">{detail}</Muted>}
|
||||||
|
{onClick && !destructive && (
|
||||||
|
<ChevronRight className="text-muted-foreground size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsGroup({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
|
|
||||||
|
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<WindowControls />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="no-drag text-muted-foreground"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">Settings</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{/* Profile header */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-5">
|
||||||
|
<Avatar size="lg">
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{user?.email_prefix}
|
||||||
|
</p>
|
||||||
|
<Muted className="text-xs">{user?.email}</Muted>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<SettingsGroup title="Account">
|
||||||
|
<SettingsRow
|
||||||
|
icon={<User className="size-4" />}
|
||||||
|
label="Profile"
|
||||||
|
detail={user?.email_prefix}
|
||||||
|
/>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<SettingsRow
|
||||||
|
icon={<Shield className="size-4" />}
|
||||||
|
label="Privacy"
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
|
<SettingsGroup title="About">
|
||||||
|
<SettingsRow
|
||||||
|
icon={<Info className="size-4" />}
|
||||||
|
label="Version"
|
||||||
|
detail="1.0.0"
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<SettingsRow
|
||||||
|
icon={<LogOut className="size-4" />}
|
||||||
|
label="Sign out"
|
||||||
|
onClick={signOut}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user