Merge pull request #1585 from AnishSarkar22/fix/ci-ui-changes

feat(ui): Unify notifications, sidebar, and API playground UX
This commit is contained in:
Anish Sarkar 2026-07-08 04:14:49 +05:30 committed by GitHub
commit ba08d6392f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1800 additions and 2396 deletions

View file

@ -1,4 +1,4 @@
import { ApiKeysSection } from "../components/api-keys-section";
import { redirect } from "next/navigation";
export default async function PlaygroundApiKeysPage({
params,
@ -7,9 +7,5 @@ export default async function PlaygroundApiKeysPage({
}) {
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-3xl">
<ApiKeysSection workspaceId={Number(workspace_id)} />
</div>
);
redirect(`/dashboard/${workspace_id}/user-settings/api-key`);
}

View file

@ -1,82 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { toast } from "sonner";
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
/**
* One-stop API key management for the playground: the workspace API-access
* toggle (otherwise buried in workspace settings) plus the personal API key
* manager (otherwise buried in user settings).
*/
export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
const {
data: workspace,
isLoading,
refetch,
} = useQuery({
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
updateWorkspaceApiAccessMutationAtom
);
const [saving, setSaving] = useState(false);
const handleToggle = async (enabled: boolean) => {
try {
setSaving(true);
await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
await refetch();
} catch (error) {
console.error("Error updating API access:", error);
toast.error(error instanceof Error ? error.message : "Failed to update API access");
} finally {
setSaving(false);
}
};
const apiAccessEnabled = !!workspace?.api_access_enabled;
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">API Keys</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enable API access for this workspace and manage the keys that use it.
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<Label htmlFor="playground-api-access">API key access</Label>
<p className="text-xs text-muted-foreground">
Allow API keys to access this workspace.
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
</p>
</div>
{isLoading ? (
<Skeleton className="h-5 w-9 rounded-full" />
) : (
<Switch
id="playground-api-access"
checked={apiAccessEnabled}
disabled={saving}
onCheckedChange={handleToggle}
/>
)}
</div>
<ApiKeyContent />
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { Check, Copy } from "lucide-react";
import { Check, ChevronRight, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -22,10 +22,10 @@ function CopyButton({ text }: { text: string }) {
variant="ghost"
size="sm"
onClick={copy}
className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
aria-label={copied ? "Copied" : "Copy"}
className="absolute right-2 top-2 h-7 w-7 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record<string,
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
return (
<details className="group rounded-md border border-border/60">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
{title}
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span>{title}</span>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
</summary>
<div className="relative border-t border-border/60">
<CopyButton text={json} />
@ -90,11 +91,7 @@ export function ApiReference({
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Call this API from your own project. Create a key under{" "}
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
this workspace), then send it as a{" "}
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
header.
Create an API key, enable API access for this workspace, then use the examples below to call this endpoint.
</p>
</div>

View file

@ -3,6 +3,7 @@
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
@ -12,7 +13,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
import { cn } from "@/lib/utils";
const MAX_TABLE_ROWS = 200;
@ -109,34 +109,12 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
{items && (
<button
type="button"
onClick={() => setView("table")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "table"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Table
</button>
)}
<button
type="button"
onClick={() => setView("json")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "json"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
JSON
</button>
</div>
<Tabs value={view} onValueChange={(value) => setView(value as "table" | "json")}>
<TabsList className="h-auto">
{items && <TabsTrigger value="table">Table</TabsTrigger>}
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{items && items.length > 0 && (
<Button
@ -150,9 +128,15 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
Export CSV
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
aria-label={copied ? "Copied JSON" : "Copy JSON"}
className="h-8 w-8 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy JSON"}
</Button>
</div>
</div>
@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
<ResultTable items={items} />
{truncated && (
<p className="text-xs text-muted-foreground">
Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
output.
</p>
)}

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowRight, History, KeyRound } from "lucide-react";
import { ArrowRight, History, Info } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatPricing } from "@/lib/playground/format";
@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">API Playground</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
captured under Runs.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
<p>
Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
<Link
href={`/dashboard/${workspaceId}/user-settings/api-key`}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
create an API key
</Link>
.
</p>
</AlertDescription>
</Alert>
<div className="grid gap-3 sm:grid-cols-2">
<Link
@ -36,27 +45,12 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Runs</p>
<p className="text-sm font-medium">API Runs</p>
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Link
href={`${base}/api-keys`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">
Enable workspace access and manage keys
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
<div className="space-y-6">

View file

@ -1,14 +1,23 @@
"use client";
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
import {
Check,
Copy,
Hash,
Info,
Coins,
Timer,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AbortedError, AppError } from "@/lib/error";
import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
@ -58,36 +67,44 @@ function RunStat({
);
}
function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
if (error instanceof AbortedError) {
return null;
}
function getRunErrorMessage(error: unknown): string {
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">Insufficient credits</p>
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
<Button asChild size="sm" variant="outline" className="mt-3">
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
</Button>
</div>
);
return "Insufficient credits. Add credits to run this API.";
}
const message =
status === 422
? "Invalid input. Check the fields above and try again."
: error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
if (status === 422) {
return "Invalid input. Check the fields above and try again.";
}
return error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
}
function EndpointCopyButton({ endpoint }: { endpoint: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(endpoint).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{message}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-auto justify-start gap-2 rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<code>{endpoint}</code>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
</Button>
);
}
@ -110,6 +127,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const [values, setValues] = useState<Record<string, unknown>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
const notifiedRunRef = useRef<string | null>(null);
// Seed form defaults once the schema is available.
useEffect(() => {
@ -158,6 +177,29 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
[run.detail]
);
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
useEffect(() => {
const previousStatus = previousStatusRef.current;
previousStatusRef.current = run.status;
if (previousStatus !== "running") return;
if (run.status === "success") {
const key = `${run.runId ?? "run"}:success`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
toast.success("API run completed.");
return;
}
if (run.status === "error") {
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
toast.error(getRunErrorMessage(run.error));
}
}, [run.status, run.runId, run.error]);
if (isLoading) {
return (
@ -186,26 +228,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
return (
<div className="space-y-10">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
{capability.description && (
<Alert>
<Info />
<AlertDescription>
<p>
{capability.description}
{capability.docs_url ? (
<>
{" "}
<Link
href={capability.docs_url}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Read docs
</Link>
{" "}for more info.
</>
) : null}
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold">
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
</h1>
{capability.description && (
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
)}
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
</code>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="h-3.5 w-3.5" />
<div className="space-y-2">
<EndpointCopyButton endpoint={endpoint} />
<div className="text-xs text-muted-foreground">
<span>Pricing: </span>
<span className="font-medium tabular-nums text-foreground">
{formatPricing(capability.pricing)}
</span>
</div>
</div>
<SchemaForm
fields={fields}
values={values}
@ -214,23 +270,17 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
{isRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Run
<Button type="button" onClick={handleRun} disabled={isRunning} className="relative">
<span className={isRunning ? "opacity-0" : ""}>Run</span>
{isRunning && <Spinner size="sm" className="absolute" />}
</Button>
{isRunning && (
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
<X className="h-4 w-4" />
<Button type="button" variant="secondary" onClick={run.cancel}>
Cancel
</Button>
)}
</div>
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
</div>
<div className="space-y-3">

View file

@ -1,8 +1,9 @@
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { ChevronDown, ChevronRight, History, Info } from "lucide-react";
import { Fragment, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
@ -67,13 +68,12 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every platform-native API run in this workspace from the playground, API keys, and
agents. Newest first.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
View all API runs for this workspace, including runs from the playground, API keys, and agents.
</AlertDescription>
</Alert>
<div className="flex flex-wrap items-center gap-2">
<Select value={capability} onValueChange={setCapability}>

View file

@ -2,6 +2,7 @@
import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -133,7 +134,12 @@ function FieldRow({
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
{field.title}
</Label>
{field.required && <span className="text-xs text-destructive">required</span>}
<Badge
variant={field.required ? "destructive" : "secondary"}
className="px-1.5 py-0 text-[10px]"
>
{field.required ? "required" : "optional"}
</Badge>
</div>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>

View file

@ -0,0 +1,95 @@
"use client";
import { History, LayoutGrid } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import type React from "react";
import { useMemo } from "react";
import {
type RoutedSectionGroup,
type RoutedSectionItem,
RoutedSectionShell,
} from "@/components/layout";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
interface PlaygroundLayoutShellProps {
workspaceId: string;
children: React.ReactNode;
}
export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
const segments = useSelectedLayoutSegments();
const base = `/dashboard/${workspaceId}/playground`;
const topLevelItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "overview",
label: "Overview",
href: base,
icon: <LayoutGrid className="h-4 w-4" />,
},
{
value: "runs",
label: "API Runs",
href: `${base}/runs`,
icon: <History className="h-4 w-4" />,
},
],
[base]
);
const providerGroups = useMemo<RoutedSectionGroup[]>(
() =>
PLAYGROUND_PLATFORMS.map((platform) => {
const Icon = platform.icon;
return {
value: platform.id,
label: platform.label,
icon: <Icon className="h-4 w-4 shrink-0" />,
items: platform.verbs.map((verb) => ({
value: `${platform.id}/${verb.verb}`,
label: verb.label,
href: `${base}/${platform.id}/${verb.verb}`,
})),
};
}),
[base]
);
const activeValue =
segments.length >= 2
? `${segments[0]}/${segments[1]}`
: segments[0] && topLevelItems.some((item) => item.value === segments[0])
? segments[0]
: "overview";
const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups);
return (
<RoutedSectionShell
title="API Playground"
items={topLevelItems}
groups={providerGroups}
activeValue={activeValue}
selectedLabel={selectedLabel}
mobileNav="drawer"
>
{children}
</RoutedSectionShell>
);
}
function getSelectedLabel(
activeValue: string,
items: RoutedSectionItem[],
groups: RoutedSectionGroup[]
): string {
const topLevelItem = items.find((item) => item.value === activeValue);
if (topLevelItem) return topLevelItem.label;
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
const child = group?.items.find((item) => item.value === activeValue);
if (group && child) return `${group.label}: ${child.label}`;
return "API Playground";
}

View file

@ -0,0 +1,15 @@
import type React from "react";
import { use } from "react";
import { PlaygroundLayoutShell } from "./layout-shell";
export default function PlaygroundLayout({
params,
children,
}: {
params: Promise<{ workspace_id: string }>;
children: React.ReactNode;
}) {
const { workspace_id } = use(params);
return <PlaygroundLayoutShell workspaceId={workspace_id}>{children}</PlaygroundLayoutShell>;
}

View file

@ -108,9 +108,8 @@ function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
function formatGranted(p: UnifiedPurchase): string {
if (p.kind === "credits") {
const dollars = p.granted / 1_000_000;
// Credit packs are always whole dollars at the moment, but future
// fractional grants (refunds, partial top-ups, auto-reload) shouldn't
// silently round to "$0".
// Credit packs are always whole dollars today, but future fractional grants
// such as refunds or low-balance refills shouldn't silently round to "$0".
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
return "$0 of credit";

View file

@ -11,14 +11,12 @@ import {
ShieldCheck,
WandSparkles,
} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { useMemo } from "react";
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
import { usePlatform } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
@ -42,50 +40,49 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
const t = useTranslations("userSettings");
const { isDesktop } = usePlatform();
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const navItems = useMemo(
const navItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/profile`,
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/api-key`,
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: "My Prompts",
href: `/dashboard/${workspaceId}/user-settings/prompts`,
icon: <WandSparkles className="h-4 w-4" />,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
icon: <Library className="h-4 w-4" />,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
icon: <MessageCircle className="h-4 w-4" />,
},
{
value: "purchases" as const,
label: "Purchase History",
href: `/dashboard/${workspaceId}/user-settings/purchases`,
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
@ -93,17 +90,19 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
{
value: "desktop" as const,
label: "App Preferences",
href: `/dashboard/${workspaceId}/user-settings/desktop`,
icon: <Monitor className="h-4 w-4" />,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
[t, isDesktop, workspaceId]
);
const activeTab: UserSettingsTab =
@ -112,70 +111,15 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}

View file

@ -1,13 +1,11 @@
"use client";
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
@ -24,44 +22,41 @@ export function WorkspaceSettingsLayoutShell({
}: WorkspaceSettingsLayoutShellProps) {
const t = useTranslations("workspaceSettings");
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const navItems = useMemo(
const navItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "general" as const,
label: t("nav_general"),
href: `/dashboard/${workspaceId}/workspace-settings/general`,
icon: <Settings className="h-4 w-4" />,
},
{
value: "models" as const,
label: t("nav_models"),
href: `/dashboard/${workspaceId}/workspace-settings/models`,
icon: <Cpu className="h-4 w-4" />,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
icon: <UserKey className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
icon: <BookText className="h-4 w-4" />,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
icon: <Earth className="h-4 w-4" />,
},
],
[t]
[t, workspaceId]
);
const activeTab: WorkspaceSettingsTab =
@ -70,71 +65,15 @@ export function WorkspaceSettingsLayoutShell({
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: WorkspaceSettingsTab) =>
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}