mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
Merge pull request #1585 from AnishSarkar22/fix/ci-ui-changes
feat(ui): Unify notifications, sidebar, and API playground UX
This commit is contained in:
commit
ba08d6392f
54 changed files with 1800 additions and 2396 deletions
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue