mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-09 15:58:30 +00:00
Merge pull request #1585 from AnishSarkar22/fix/ci-ui-changes
Some checks are pending
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
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
|
|
@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel):
|
|||
|
||||
name: str
|
||||
description: str
|
||||
docs_url: str | None = None
|
||||
input_schema: dict
|
||||
output_schema: dict
|
||||
# Empty list = free (billing disabled or an unmetered verb).
|
||||
|
|
@ -145,6 +146,7 @@ def _register_capabilities_list(
|
|||
CapabilitySummary(
|
||||
name=capability.name,
|
||||
description=capability.description,
|
||||
docs_url=capability.docs_url,
|
||||
input_schema=capability.input_schema.model_json_schema(),
|
||||
output_schema=capability.output_schema.model_json_schema(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ class Capability:
|
|||
output_schema: type[BaseModel]
|
||||
executor: Executor
|
||||
billing_unit: BillingUnit | None
|
||||
docs_url: str | None = None
|
||||
|
|
|
|||
|
|
@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu
|
|||
GOOGLE_MAPS_REVIEWS = Capability(
|
||||
name="google_maps.reviews",
|
||||
description=(
|
||||
"Fetch public reviews for one or more Google Maps places. Give it place "
|
||||
"URLs or place IDs; returns structured review items with author, text, "
|
||||
"star rating, like count, owner response, and timestamps. Use it to "
|
||||
"gauge sentiment or pull recent feedback on specific places."
|
||||
"Fetch public Google Maps reviews with authors, ratings, text, and "
|
||||
"owner responses. Use urls or place IDs."
|
||||
),
|
||||
input_schema=ReviewsInput,
|
||||
output_schema=ReviewsOutput,
|
||||
executor=build_reviews_executor(),
|
||||
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
|
||||
docs_url="/docs/connectors/native/google-maps",
|
||||
)
|
||||
|
||||
register_capability(GOOGLE_MAPS_REVIEWS)
|
||||
|
|
|
|||
|
|
@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu
|
|||
GOOGLE_MAPS_SCRAPE = Capability(
|
||||
name="google_maps.scrape",
|
||||
description=(
|
||||
"Scrape public Google Maps places. Give it search queries (optionally "
|
||||
"scoped by location), Google Maps URLs, or place IDs, and it returns "
|
||||
"structured place items — name, address, category, phone, website, "
|
||||
"rating, review count, coordinates, and opening hours. Set "
|
||||
"include_details for richer detail-page fields, or max_reviews/"
|
||||
"max_images to attach reviews and photos per place."
|
||||
"Scrape public Google Maps places, details, reviews, and photos. Use "
|
||||
"search_queries, urls, or place IDs."
|
||||
),
|
||||
input_schema=ScrapeInput,
|
||||
output_schema=ScrapeOutput,
|
||||
executor=build_scrape_executor(),
|
||||
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
|
||||
docs_url="/docs/connectors/native/google-maps",
|
||||
)
|
||||
|
||||
register_capability(GOOGLE_MAPS_SCRAPE)
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut
|
|||
GOOGLE_SEARCH_SCRAPE = Capability(
|
||||
name="google_search.scrape",
|
||||
description=(
|
||||
"Search Google and return structured results. Give it search terms "
|
||||
"(optionally scoped by country/language or to a single site) or full "
|
||||
"Google Search URLs, and it returns SERP items — organic results "
|
||||
"(title, url, description), related queries, people-also-ask, and any "
|
||||
"AI overview. Use max_pages_per_query to page deeper."
|
||||
"Search Google and return structured SERP results. Use search_queries "
|
||||
"or Google Search URLs."
|
||||
),
|
||||
input_schema=ScrapeInput,
|
||||
output_schema=ScrapeOutput,
|
||||
executor=build_scrape_executor(),
|
||||
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
|
||||
docs_url="/docs/connectors/native/google-search",
|
||||
)
|
||||
|
||||
register_capability(GOOGLE_SEARCH_SCRAPE)
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
|
|||
REDDIT_SCRAPE = Capability(
|
||||
name="reddit.scrape",
|
||||
description=(
|
||||
"Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
|
||||
"user) and/or search terms, and it returns structured items — posts "
|
||||
"(title, body, score, comment count, subreddit, author), their comments, "
|
||||
"and community/user metadata. Use search_queries (optionally scoped to a "
|
||||
"community) to discover posts, or urls to pull a known post/subreddit/user."
|
||||
"Scrape public Reddit posts, comments, and metadata. Use urls or "
|
||||
"search_queries."
|
||||
),
|
||||
input_schema=ScrapeInput,
|
||||
output_schema=ScrapeOutput,
|
||||
executor=build_scrape_executor(),
|
||||
billing_unit=BillingUnit.REDDIT_ITEM,
|
||||
docs_url="/docs/connectors/native/reddit",
|
||||
)
|
||||
|
||||
register_capability(REDDIT_SCRAPE)
|
||||
|
|
|
|||
|
|
@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
|
|||
WEB_CRAWL = Capability(
|
||||
name="web.crawl",
|
||||
description=(
|
||||
"Scrape a single web page or crawl a whole website. Give it one or more "
|
||||
"startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to "
|
||||
"also follow the links on each page (depth 1 = the start pages plus the "
|
||||
"pages they link to, and so on) — staying on the same site and stopping "
|
||||
"at maxCrawlPages. On a deeper crawl, narrow which links are followed with "
|
||||
"includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per "
|
||||
"fetched page with clean markdown content, metadata (title, description), "
|
||||
"crawl provenance, every link with its anchor text and kind "
|
||||
"(internal/external/social/email/tel — use the text/context to tie a "
|
||||
"profile URL to a person or company), and contact signals (emails, phone "
|
||||
"numbers, social profiles). The site-wide contacts summary deduplicates "
|
||||
"them with provenance: siteWide=true marks footer/header values (the "
|
||||
"company's own contacts) vs page-local finds (e.g. team members' "
|
||||
"profiles). Useful for lead generation and competitive intelligence; "
|
||||
"contact details often live on about/contact/privacy pages, so crawl "
|
||||
"with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded "
|
||||
"in a real browser and auto-scrolled, so lazy-loaded listings "
|
||||
"(directories, infinite-scroll feeds) are captured too."
|
||||
"Scrape pages or crawl websites for clean markdown, links, metadata, "
|
||||
"and contact signals. Use startUrls and crawl-depth controls."
|
||||
),
|
||||
input_schema=CrawlInput,
|
||||
output_schema=CrawlOutput,
|
||||
executor=build_crawl_executor(),
|
||||
billing_unit=BillingUnit.WEB_CRAWL,
|
||||
docs_url="/docs/connectors/native/web-crawl",
|
||||
)
|
||||
|
||||
register_capability(WEB_CRAWL)
|
||||
|
|
|
|||
|
|
@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut
|
|||
YOUTUBE_COMMENTS = Capability(
|
||||
name="youtube.comments",
|
||||
description=(
|
||||
"Fetch public comments (and their replies) for one or more YouTube "
|
||||
"videos. Give it the video URLs; returns structured comment items with "
|
||||
"author, text, like count, reply relationships, and timestamps. Use it "
|
||||
"to gauge sentiment or pull discussion on specific videos."
|
||||
"Fetch public YouTube comments and replies with authors, text, likes, "
|
||||
"and timestamps. Use video URLs."
|
||||
),
|
||||
input_schema=CommentsInput,
|
||||
output_schema=CommentsOutput,
|
||||
executor=build_comments_executor(),
|
||||
billing_unit=BillingUnit.YOUTUBE_COMMENT,
|
||||
docs_url="/docs/connectors/native/youtube",
|
||||
)
|
||||
|
||||
register_capability(YOUTUBE_COMMENTS)
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
|
|||
YOUTUBE_SCRAPE = Capability(
|
||||
name="youtube.scrape",
|
||||
description=(
|
||||
"Scrape public YouTube data. Give it YouTube URLs (video, channel, "
|
||||
"playlist, shorts, or hashtag) and/or search queries, and it returns "
|
||||
"structured video items — title, views, likes, publish date, channel "
|
||||
"info, description, and optionally subtitles. Use search_queries to "
|
||||
"discover videos, or urls to pull a known video/channel/playlist."
|
||||
"Scrape public YouTube videos, channels, playlists, and subtitles. Use "
|
||||
"urls or search_queries."
|
||||
),
|
||||
input_schema=ScrapeInput,
|
||||
output_schema=ScrapeOutput,
|
||||
executor=build_scrape_executor(),
|
||||
billing_unit=BillingUnit.YOUTUBE_VIDEO,
|
||||
docs_url="/docs/connectors/native/youtube",
|
||||
)
|
||||
|
||||
register_capability(YOUTUBE_SCRAPE)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
/**
|
||||
* Whether the second-level API Playground sidebar is open. Toggled by the
|
||||
* Playground nav button and kept in memory for the session, so it survives
|
||||
* in-app navigation (opening a new chat won't close it) and only closes when
|
||||
* the user clicks the toggle. It defaults to open, so a fresh app load — a new
|
||||
* signup or a relogin — always starts with the playground visible.
|
||||
*/
|
||||
export const playgroundSidebarOpenAtom = atom<boolean>(true);
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -119,7 +119,7 @@ import {
|
|||
} from "../new-chat/document-mention-picker";
|
||||
|
||||
const COMPOSER_PLACEHOLDER =
|
||||
"Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
|
||||
"Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
|
||||
|
||||
type ComposerSuggestionAnchorPoint = {
|
||||
left: number;
|
||||
|
|
@ -986,14 +986,12 @@ const Composer: FC = () => {
|
|||
* Full-color brand marks for the platform-native scraper APIs (web, Google
|
||||
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
|
||||
* composer "+" so the user can see these native endpoints are connected. Laid
|
||||
* out as a clean row (not stacked) after a hairline divider that separates them
|
||||
* out as the same overlapping avatar group used by the connect-tools tray
|
||||
* from the composer actions. The capability registry is the source of truth;
|
||||
* icons are display-only with a status tooltip. One-time staggered entrance,
|
||||
* reduced-motion aware.
|
||||
* icons are display-only with a status tooltip.
|
||||
*/
|
||||
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
|
||||
const { data: capabilities } = useScraperCapabilities(workspaceId);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const platforms = useMemo<PlaygroundPlatform[]>(() => {
|
||||
if (!capabilities?.length) return [];
|
||||
|
|
@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
|
|||
return (
|
||||
<div className="hidden items-center gap-1 sm:flex">
|
||||
<div aria-hidden className="h-5 w-px shrink-0 bg-border" />
|
||||
<div className="flex items-center gap-0.5" aria-label="Connected data sources">
|
||||
<AvatarGroup className="shrink-0">
|
||||
{platforms.map((platform, i) => {
|
||||
const Icon = platform.icon;
|
||||
return (
|
||||
<Tooltip key={platform.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
delay: reduceMotion ? 0 : i * 0.04,
|
||||
}}
|
||||
className="flex size-5 items-center justify-center"
|
||||
<Avatar
|
||||
className="size-5"
|
||||
style={{ zIndex: platforms.length - i }}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</motion.span>
|
||||
<AvatarFallback className="bg-popover text-[10px]">
|
||||
<Icon className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{platform.label} · Connected</TooltipContent>
|
||||
<TooltipContent side="bottom">{platform.label} scraper available</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null);
|
||||
const [isRescanning, setIsRescanning] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleRescan = useCallback(async () => {
|
||||
if (isRescanning) return;
|
||||
|
|
@ -349,12 +350,17 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export type HeroChatDemoScript = {
|
|||
|
||||
type Stage = "typing" | "steps" | "answer" | "done";
|
||||
|
||||
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
|
||||
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
|
||||
|
||||
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
|
||||
function Caret() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type {
|
|||
User,
|
||||
Workspace,
|
||||
} from "./types/layout.types";
|
||||
export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
|
||||
export {
|
||||
ChatListItem,
|
||||
CreateWorkspaceDialog,
|
||||
|
|
@ -20,6 +21,7 @@ export {
|
|||
MobileSidebarTrigger,
|
||||
NavIcon,
|
||||
NavSection,
|
||||
RoutedSectionShell,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
|
||||
interface FreeLayoutDataProviderProps {
|
||||
|
|
@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
|
||||
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: false,
|
||||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[]
|
||||
);
|
||||
|
||||
const pageUsage: PageUsage | undefined = quota
|
||||
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
||||
: undefined;
|
||||
|
||||
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.title === "Inbox") gate("use the inbox");
|
||||
},
|
||||
[gate]
|
||||
);
|
||||
|
||||
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
|
||||
|
||||
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
|
||||
|
|
@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onWorkspaceSettings={gatedAction("workspace settings")}
|
||||
onAddWorkspace={gatedAction("create workspaces")}
|
||||
workspace={GUEST_SPACE}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
navItems={[]}
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
onNewChat={resetChat}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -11,7 +11,6 @@ import { toast } from "sonner";
|
|||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
|
||||
import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom";
|
||||
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
|
|
@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
|
|
@ -60,17 +58,6 @@ interface LayoutDataProviderProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
|
|
@ -79,8 +66,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom);
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
|
@ -125,12 +110,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
// Unified slide-out panel state (only one can be open at a time)
|
||||
type SlideoutPanel = "inbox" | null;
|
||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
||||
|
||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
|
||||
|
||||
|
|
@ -228,17 +207,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
||||
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
|
||||
|
||||
// Reset transient slide-out panels when switching workspaces.
|
||||
// Tabs intentionally persist across spaces — opening tabs from multiple
|
||||
// workspaces is a supported flow (browser-tab semantics).
|
||||
const prevWorkspaceIdRef = useRef(workspaceId);
|
||||
useEffect(() => {
|
||||
if (prevWorkspaceIdRef.current !== workspaceId) {
|
||||
prevWorkspaceIdRef.current = workspaceId;
|
||||
setActiveSlideoutPanel(null);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const workspaces: Workspace[] = useMemo(() => {
|
||||
if (!workspacesData || !Array.isArray(workspacesData)) return [];
|
||||
return workspacesData.map((space) => ({
|
||||
|
|
@ -318,9 +286,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
}, [threadsData, workspaceId]);
|
||||
|
||||
// Navigation items
|
||||
// Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
||||
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
|
||||
// Automations and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar. Documents is embedded below Recents; notifications and
|
||||
// announcements live in the avatar rail/dropdown.
|
||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
|
|
@ -328,13 +296,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Automations",
|
||||
url: `/dashboard/${workspaceId}/automations`,
|
||||
|
|
@ -351,22 +312,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
title: "Playground",
|
||||
url: `/dashboard/${workspaceId}/playground`,
|
||||
icon: SquareTerminal,
|
||||
// Mobile has no second-level sidebar: Playground is a plain link
|
||||
// there, so highlight by route. Desktop highlights the toggle state.
|
||||
isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen,
|
||||
isActive: isPlaygroundRoute,
|
||||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[
|
||||
isInboxSidebarOpen,
|
||||
totalUnreadCount,
|
||||
workspaceId,
|
||||
isAutomationsActive,
|
||||
isArtifactsActive,
|
||||
isPlaygroundRoute,
|
||||
playgroundSidebarOpen,
|
||||
isMobile,
|
||||
]
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -503,22 +453,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.url === "#inbox") {
|
||||
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
|
||||
return;
|
||||
}
|
||||
// Desktop: Playground is a persistent toggle, not a plain link — it just
|
||||
// opens the second-level sidebar (which holds the whole API playground)
|
||||
// and only closes on a second click, never navigating away from the
|
||||
// current page (e.g. a new chat). Mobile has no second-level sidebar,
|
||||
// so there it navigates to the playground index page instead.
|
||||
if (item.url.endsWith("/playground") && !isMobile) {
|
||||
setPlaygroundSidebarOpen((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -688,7 +625,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setActiveSlideoutPanel(null);
|
||||
router.push(
|
||||
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
|
||||
);
|
||||
|
|
@ -741,7 +677,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
setTheme={setTheme}
|
||||
isChatPage={isChatPage}
|
||||
isAllChatsPage={isAllChatsPage}
|
||||
showPlaygroundSidebar={playgroundSidebarOpen}
|
||||
useWorkspacePanel={useWorkspacePanel}
|
||||
workspacePanelViewportClassName={
|
||||
isUserSettingsPage ||
|
||||
|
|
@ -755,23 +690,15 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
: undefined
|
||||
}
|
||||
workspacePanelContentClassName={
|
||||
isAutomationsPage || isPlaygroundPage
|
||||
? "max-w-none select-none"
|
||||
: isAllChatsPage
|
||||
? "max-w-5xl"
|
||||
: isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage
|
||||
? "max-w-5xl"
|
||||
: undefined
|
||||
useWorkspacePanel ? "max-w-5xl select-none" : undefined
|
||||
}
|
||||
isLoadingChats={isLoadingThreads}
|
||||
activeSlideoutPanel={activeSlideoutPanel}
|
||||
onSlideoutPanelChange={setActiveSlideoutPanel}
|
||||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
notifications={{
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
items: commentsInbox.inboxItems,
|
||||
unreadCount: commentsInbox.unreadCount,
|
||||
totalCount: commentsInbox.totalCount,
|
||||
loading: commentsInbox.loading,
|
||||
loadingMore: commentsInbox.loadingMore,
|
||||
hasMore: commentsInbox.hasMore,
|
||||
|
|
@ -782,6 +709,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
status: {
|
||||
items: statusInbox.inboxItems,
|
||||
unreadCount: statusInbox.unreadCount,
|
||||
totalCount: statusInbox.totalCount,
|
||||
loading: statusInbox.loading,
|
||||
loadingMore: statusInbox.loadingMore,
|
||||
hasMore: statusInbox.hasMore,
|
||||
|
|
|
|||
267
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
267
surfsense_web/components/layout/ui/RoutedSectionShell.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RoutedSectionItem {
|
||||
value: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface RoutedSectionGroup {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
items: RoutedSectionItem[];
|
||||
}
|
||||
|
||||
interface RoutedSectionShellProps {
|
||||
title: string;
|
||||
items: RoutedSectionItem[];
|
||||
activeValue: string;
|
||||
selectedLabel: string;
|
||||
children: React.ReactNode;
|
||||
groups?: RoutedSectionGroup[];
|
||||
mobileNav?: "scroll" | "drawer";
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
|
||||
return (
|
||||
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function SectionNavLink({
|
||||
item,
|
||||
activeValue,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: RoutedSectionItem;
|
||||
activeValue: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const isActive = activeValue === item.value;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch
|
||||
onClick={onNavigate}
|
||||
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",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionNavGroup({
|
||||
group,
|
||||
activeValue,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: RoutedSectionGroup;
|
||||
activeValue: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={onToggle}
|
||||
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",
|
||||
isExpanded
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{group.icon}
|
||||
<span className="min-w-0 truncate">{group.label}</span>
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
|
||||
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
aria-hidden={!isExpanded}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-0.5 pl-10">
|
||||
{group.items.map((item) => (
|
||||
<SectionNavLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
activeValue={activeValue}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutedSectionShell({
|
||||
title,
|
||||
items,
|
||||
activeValue,
|
||||
selectedLabel,
|
||||
children,
|
||||
groups = [],
|
||||
mobileNav = "scroll",
|
||||
contentClassName,
|
||||
}: RoutedSectionShellProps) {
|
||||
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
|
||||
findActiveGroupValue(groups, activeValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findActiveGroupValue(groups, activeValue);
|
||||
if (activeGroup) {
|
||||
setExpandedGroup(activeGroup);
|
||||
}
|
||||
}, [activeValue, groups]);
|
||||
|
||||
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 renderNav = (onNavigate?: () => void) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<SectionNavLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
activeValue={activeValue}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-3 bg-border" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((group) => (
|
||||
<SectionNavGroup
|
||||
key={group.value}
|
||||
group={group}
|
||||
activeValue={activeValue}
|
||||
isExpanded={expandedGroup === group.value}
|
||||
onToggle={() =>
|
||||
setExpandedGroup((current) => (current === group.value ? null : group.value))
|
||||
}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
|
||||
{mobileNav === "drawer" ? (
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="sr-only">{title} navigation</DrawerTitle>
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
|
||||
{renderNav(() => setDrawerOpen(false))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<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">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.value}
|
||||
href={item.href}
|
||||
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",
|
||||
activeValue === 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={cn("min-w-0 pt-4", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem, User, Workspace } from "../../types/layout.types";
|
||||
import {
|
||||
NotificationsDropdown,
|
||||
type NotificationsDropdownData,
|
||||
} from "../sidebar/NotificationsDropdown";
|
||||
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
|
||||
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
|
||||
|
|
@ -24,6 +28,7 @@ interface IconRailProps {
|
|||
onUserSettings?: () => void;
|
||||
onAnnouncements?: () => void;
|
||||
announcementUnreadCount?: number;
|
||||
notifications?: NotificationsDropdownData;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
|
|
@ -45,6 +50,7 @@ export function IconRail({
|
|||
onUserSettings,
|
||||
onAnnouncements,
|
||||
announcementUnreadCount = 0,
|
||||
notifications,
|
||||
onLogout,
|
||||
theme,
|
||||
setTheme,
|
||||
|
|
@ -145,6 +151,9 @@ export function IconRail({
|
|||
isCollapsed
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
topContent={
|
||||
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export { CreateWorkspaceDialog } from "./dialogs";
|
||||
export { Header } from "./header";
|
||||
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
||||
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
|
||||
export { RoutedSectionShell } from "./RoutedSectionShell";
|
||||
export { LayoutShell } from "./shell";
|
||||
export {
|
||||
ChatListItem,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -27,14 +25,12 @@ import {
|
|||
RightPanelToggleButton,
|
||||
} from "../right-panel/RightPanel";
|
||||
import {
|
||||
InboxSidebarContent,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
PlaygroundSidebar,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
} from "../sidebar";
|
||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
|
||||
import { TabBar } from "../tabs/TabBar";
|
||||
import { WorkspacePanel } from "./WorkspacePanel";
|
||||
|
||||
|
|
@ -80,28 +76,6 @@ function MacDesktopTitleBar({
|
|||
);
|
||||
}
|
||||
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type ActiveSlideoutPanel = "inbox" | null;
|
||||
|
||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||
interface InboxProps {
|
||||
isOpen: boolean;
|
||||
totalUnreadCount: number;
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
}
|
||||
|
||||
interface LayoutShellProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
|
|
@ -134,17 +108,12 @@ interface LayoutShellProps {
|
|||
defaultCollapsed?: boolean;
|
||||
isChatPage?: boolean;
|
||||
isAllChatsPage?: boolean;
|
||||
showPlaygroundSidebar?: boolean;
|
||||
useWorkspacePanel?: boolean;
|
||||
workspacePanelViewportClassName?: string;
|
||||
workspacePanelContentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
// Unified slide-out panel state
|
||||
activeSlideoutPanel?: ActiveSlideoutPanel;
|
||||
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
|
||||
// Inbox props
|
||||
inbox?: InboxProps;
|
||||
notifications?: NotificationsDropdownData;
|
||||
isLoadingChats?: boolean;
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
|
|
@ -239,15 +208,12 @@ export function LayoutShell({
|
|||
defaultCollapsed = false,
|
||||
isChatPage = false,
|
||||
isAllChatsPage = false,
|
||||
showPlaygroundSidebar = false,
|
||||
useWorkspacePanel = false,
|
||||
workspacePanelViewportClassName,
|
||||
workspacePanelContentClassName,
|
||||
children,
|
||||
className,
|
||||
activeSlideoutPanel = null,
|
||||
onSlideoutPanelChange,
|
||||
inbox,
|
||||
notifications,
|
||||
isLoadingChats = false,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
|
|
@ -269,17 +235,6 @@ export function LayoutShell({
|
|||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||
);
|
||||
|
||||
const closeSlideout = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onSlideoutPanelChange?.(null);
|
||||
},
|
||||
[onSlideoutPanelChange]
|
||||
);
|
||||
|
||||
const anySlideOutOpen = activeSlideoutPanel !== null;
|
||||
|
||||
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
|
@ -316,6 +271,7 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
|
|
@ -335,34 +291,6 @@ export function LayoutShell({
|
|||
{children}
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Mobile unified slide-out panel */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
|
|
@ -400,36 +328,18 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Playground second-level sidebar — contextual, desktop only. Sits
|
||||
between the icon rail and the main sidebar. On Mac it becomes the
|
||||
leftmost panel, so it takes the rounded-corner/left-border treatment. */}
|
||||
{showPlaygroundSidebar && activeWorkspaceId != null && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
||||
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
|
||||
)}
|
||||
>
|
||||
<PlaygroundSidebar workspaceId={activeWorkspaceId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
||||
isMacDesktop
|
||||
? cn(
|
||||
"border-t border-r",
|
||||
!showPlaygroundSidebar && "rounded-tl-xl border-l"
|
||||
)
|
||||
: "border-r"
|
||||
isMacDesktop ? "rounded-tl-xl border-l border-t border-r" : "border-r"
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
|
|
@ -467,7 +377,7 @@ export function LayoutShell({
|
|||
}
|
||||
className={cn(
|
||||
"flex shrink-0",
|
||||
isMacDesktop && !showPlaygroundSidebar && "rounded-tl-xl"
|
||||
isMacDesktop && "rounded-tl-xl"
|
||||
)}
|
||||
isLoadingChats={isLoadingChats}
|
||||
sidebarWidth={sidebarWidth}
|
||||
|
|
@ -491,33 +401,6 @@ export function LayoutShell({
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified slide-out panel — shell stays open, content cross-fades */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
|
||||
<DesktopWorkspaceRegion>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function ChatListItem({
|
|||
const dropdownOpen = controlledOpen ?? internalOpen;
|
||||
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
|
||||
const animatedName = useTypewriter(name);
|
||||
const isHighlighted = isActive || dropdownOpen;
|
||||
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
|
||||
|
|
@ -67,9 +68,9 @@ export function ChatListItem({
|
|||
onFocus={onPrefetch}
|
||||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={sidebarListItemClassName({
|
||||
active: isActive,
|
||||
active: isHighlighted,
|
||||
className:
|
||||
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal group-hover/item:bg-accent group-hover/item:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
})}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
|
||||
|
|
@ -79,7 +80,7 @@ export function ChatListItem({
|
|||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
||||
isActive
|
||||
isHighlighted
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function formatUsd(micros: number): string {
|
|||
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
|
||||
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
|
||||
* the amount when it falls below $0.50 so the user knows to top up or enable
|
||||
* auto-reload.
|
||||
* automatic top-ups.
|
||||
*/
|
||||
export function CreditBalanceDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
|
|
@ -46,7 +46,7 @@ export function CreditBalanceDisplay() {
|
|||
"font-medium tabular-nums",
|
||||
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
|
||||
)}
|
||||
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
|
||||
title={isLow ? "Low balance: add credits or enable top-ups" : undefined}
|
||||
>
|
||||
{formatUsd(balanceMicros)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
Check,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
FolderSync,
|
||||
|
|
@ -50,6 +51,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
|
@ -69,6 +71,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
|||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
|
|
@ -127,60 +130,111 @@ export function EmbeddedDocumentsMenu({
|
|||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||
onCreateFolder: () => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const documentTypes = useMemo(
|
||||
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
|
||||
[typeCounts]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Document actions"
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Document actions"
|
||||
>
|
||||
<SlidersVertical className="size-3.5" />
|
||||
{activeTypes.length > 0 ? (
|
||||
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={onCreateFolder}>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
New folder
|
||||
</DropdownMenuItem>
|
||||
{isMobile ? (
|
||||
<DropdownMenuItem onSelect={() => setFilterDrawerOpen(true)}>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
<span className="flex-1">Filter by type</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
Filter by type
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
|
||||
{documentTypes.length > 0 ? (
|
||||
documentTypes.map((type) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={type}
|
||||
checked={activeTypes.includes(type)}
|
||||
onCheckedChange={(checked) => onToggleType(type, checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{getDocumentTypeIcon(type, "h-4 w-4")}
|
||||
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{typeCounts[type] ?? 0}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Drawer
|
||||
open={filterDrawerOpen}
|
||||
onOpenChange={setFilterDrawerOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent
|
||||
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<SlidersVertical className="h-3.5 w-3.5" />
|
||||
{activeTypes.length > 0 ? (
|
||||
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={onCreateFolder}>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
New folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
|
||||
Filter by type
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
|
||||
</DrawerTitle>
|
||||
<div className="px-4 pb-6 pt-1">
|
||||
{documentTypes.length > 0 ? (
|
||||
documentTypes.map((type) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={type}
|
||||
checked={activeTypes.includes(type)}
|
||||
onCheckedChange={(checked) => onToggleType(type, checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{getDocumentTypeIcon(type, "h-4 w-4")}
|
||||
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{typeCounts[type] ?? 0}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
documentTypes.map((type, index) => {
|
||||
const isActive = activeTypes.includes(type);
|
||||
return (
|
||||
<div key={type}>
|
||||
{index > 0 && <div className="mx-3 h-px bg-popover-border" />}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onClick={() => onToggleType(type, !isActive)}
|
||||
>
|
||||
{getDocumentTypeIcon(type, "h-4 w-4")}
|
||||
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
||||
<span className="text-xs text-muted-foreground">{typeCounts[type] ?? 0}</span>
|
||||
{isActive && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">No document types</p>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -230,10 +284,10 @@ export function EmbeddedImportMenu({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Import documents"
|
||||
>
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
<FolderInput className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
|
|
@ -1213,7 +1267,7 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
defaultOpen={true}
|
||||
contentClassName="px-0"
|
||||
persistentAction={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmbeddedImportMenu onFolderWatched={refreshWatchedIds} />
|
||||
<EmbeddedDocumentsMenu
|
||||
typeCounts={typeCounts}
|
||||
|
|
@ -1389,7 +1443,7 @@ function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps)
|
|||
defaultOpen={true}
|
||||
contentClassName="px-0"
|
||||
persistentAction={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmbeddedImportMenu gate={gate} />
|
||||
<EmbeddedDocumentsMenu
|
||||
typeCounts={hasDoc ? { FILE: 1 } : {}}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
|
||||
import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ interface MobileSidebarProps {
|
|||
onUserSettings?: () => void;
|
||||
onAnnouncements?: () => void;
|
||||
announcementUnreadCount?: number;
|
||||
notifications?: NotificationsDropdownData;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
theme?: string;
|
||||
|
|
@ -83,6 +85,7 @@ export function MobileSidebar({
|
|||
onUserSettings,
|
||||
onAnnouncements,
|
||||
announcementUnreadCount = 0,
|
||||
notifications,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
theme,
|
||||
|
|
@ -161,11 +164,19 @@ export function MobileSidebar({
|
|||
isCollapsed
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
topContent={
|
||||
notifications ? (
|
||||
<NotificationsDropdown
|
||||
notifications={notifications}
|
||||
onCloseMobileSidebar={() => onOpenChange(false)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content - right side */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
|
||||
<div className="flex-1 overflow-hidden flex flex-col border-r [&>*]:!w-full">
|
||||
<Sidebar
|
||||
workspace={workspace}
|
||||
isCollapsed={false}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,460 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
isCommentReplyMetadata,
|
||||
isInsufficientCreditsMetadata,
|
||||
isNewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NotificationsDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
totalCount?: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface NotificationsDropdownData {
|
||||
totalUnreadCount: number;
|
||||
comments: NotificationsDataSource;
|
||||
status: NotificationsDataSource;
|
||||
}
|
||||
|
||||
interface NotificationsDropdownProps {
|
||||
notifications: NotificationsDropdownData;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
type NotificationFilter = "all" | "mentions" | "unread";
|
||||
|
||||
function formatNotificationCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return "now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return `${Math.floor(diffDays / 7)}w ago`;
|
||||
} catch {
|
||||
return "now";
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentNotification(item: InboxItem): boolean {
|
||||
return item.type === "new_mention" || item.type === "comment_reply";
|
||||
}
|
||||
|
||||
export function NotificationsDropdown({
|
||||
notifications,
|
||||
onCloseMobileSidebar,
|
||||
}: NotificationsDropdownProps) {
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<NotificationFilter>("all");
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
|
||||
const allCount =
|
||||
(notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0);
|
||||
const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length;
|
||||
const visibleUnreadCount =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.unreadCount
|
||||
: notifications.totalUnreadCount;
|
||||
const isLoading =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.loading
|
||||
: notifications.comments.loading || notifications.status.loading;
|
||||
const isLoadingMore =
|
||||
activeFilter === "mentions"
|
||||
? !!notifications.comments.loadingMore
|
||||
: !!notifications.comments.loadingMore || !!notifications.status.loadingMore;
|
||||
const hasMore =
|
||||
activeFilter === "mentions"
|
||||
? !!notifications.comments.hasMore
|
||||
: !!notifications.comments.hasMore || !!notifications.status.hasMore;
|
||||
const items = useMemo(() => {
|
||||
const sourceItems =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.items
|
||||
: [...notifications.comments.items, ...notifications.status.items];
|
||||
|
||||
return sourceItems
|
||||
.filter((item) => activeFilter !== "unread" || !item.read)
|
||||
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
}, [activeFilter, notifications.comments.items, notifications.status.items]);
|
||||
|
||||
const loadMoreForActiveFilter = useCallback(() => {
|
||||
if (isLoadingMore) return;
|
||||
|
||||
if (activeFilter === "mentions") {
|
||||
if (notifications.comments.hasMore) {
|
||||
notifications.comments.loadMore?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (notifications.comments.hasMore) {
|
||||
notifications.comments.loadMore?.();
|
||||
}
|
||||
if (notifications.status.hasMore) {
|
||||
notifications.status.loadMore?.();
|
||||
}
|
||||
}, [activeFilter, isLoadingMore, notifications.comments, notifications.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isLoading || isLoadingMore || !hasMore) return;
|
||||
const root = scrollContainerRef.current;
|
||||
const target = loadMoreTriggerRef.current;
|
||||
if (!root || !target) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
loadMoreForActiveFilter();
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: "120px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]);
|
||||
|
||||
const markItemAsRead = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (item.read) return;
|
||||
setMarkingAsReadId(item.id);
|
||||
try {
|
||||
await (isCommentNotification(item)
|
||||
? notifications.comments.markAsRead(item.id)
|
||||
: notifications.status.markAsRead(item.id));
|
||||
} finally {
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
},
|
||||
[notifications.comments, notifications.status]
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
await markItemAsRead(item);
|
||||
|
||||
if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
|
||||
const threadId = item.metadata.thread_id;
|
||||
const commentId = item.metadata.comment_id;
|
||||
if (item.workspace_id && threadId) {
|
||||
if (commentId) setTargetCommentId(commentId);
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(
|
||||
commentId
|
||||
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
|
||||
const threadId = item.metadata.thread_id;
|
||||
const replyId = item.metadata.reply_id;
|
||||
if (item.workspace_id && threadId) {
|
||||
if (replyId) setTargetCommentId(replyId);
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(
|
||||
replyId
|
||||
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
|
||||
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
|
||||
if (item.metadata.action_url) {
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(item.metadata.action_url);
|
||||
}
|
||||
}
|
||||
},
|
||||
[markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
if (visibleUnreadCount === 0 || markingAllAsRead) return;
|
||||
setMarkingAllAsRead(true);
|
||||
try {
|
||||
if (activeFilter === "mentions") {
|
||||
await notifications.comments.markAllAsRead();
|
||||
} else {
|
||||
await Promise.all([
|
||||
notifications.comments.markAllAsRead(),
|
||||
notifications.status.markAllAsRead(),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setMarkingAllAsRead(false);
|
||||
}
|
||||
}, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
|
||||
|
||||
const emptyStateCopy =
|
||||
activeFilter === "mentions"
|
||||
? {
|
||||
title: "No mentions",
|
||||
description: "Mentions and replies will appear here.",
|
||||
}
|
||||
: activeFilter === "unread"
|
||||
? {
|
||||
title: "No unread notifications",
|
||||
description: "New mentions and status updates will appear here.",
|
||||
}
|
||||
: {
|
||||
title: "No notifications",
|
||||
description: "Mentions, replies, and status updates will appear here.",
|
||||
};
|
||||
|
||||
const tabs: { value: NotificationFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "All", count: allCount },
|
||||
{ value: "mentions", label: "Mentions", count: mentionsCount },
|
||||
{ value: "unread", label: "Unread", count: notifications.totalUnreadCount },
|
||||
];
|
||||
|
||||
const triggerButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Notifications"
|
||||
className={cn(
|
||||
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
open && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{notifications.totalUnreadCount > 0 ? (
|
||||
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
|
||||
{unreadLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const panelContent = (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold">Notifications</h2>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={visibleUnreadCount === 0 || markingAllAsRead}
|
||||
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
{markingAllAsRead ? <Spinner size="xs" /> : null}
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex shrink-0 items-end gap-4 px-4 after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-muted-foreground/25 after:content-['']">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeFilter === tab.value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => setActiveFilter(tab.value)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-11 items-center gap-2 border-b-2 border-transparent px-0 text-sm font-medium text-muted-foreground transition-colors",
|
||||
"hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground"
|
||||
>
|
||||
{formatNotificationCount(tab.count)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{[82, 64, 74].map((width) => (
|
||||
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
|
||||
<Skeleton className="h-2.5 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isMarkingAsRead}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className={cn(
|
||||
"group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
!item.read && "bg-accent/40"
|
||||
)}
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-1 flex-1 text-sm font-medium",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
{!item.read ? (
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
|
||||
{item.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
|
||||
{formatTime(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{hasMore ? (
|
||||
<div
|
||||
ref={loadMoreTriggerRef}
|
||||
className="flex min-h-10 items-center justify-center py-2"
|
||||
>
|
||||
{isLoadingMore ? <Spinner size="xs" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadMoreForActiveFilter}
|
||||
disabled={isLoadingMore}
|
||||
className="mt-3 text-xs"
|
||||
>
|
||||
{isLoadingMore ? <Spinner size="xs" /> : null}
|
||||
Load more
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="z-80 h-[78vh] max-h-[90vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
|
||||
<div className="flex min-h-0 flex-1 select-none flex-col">{panelContent}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Notifications
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="z-80 flex h-[min(420px,calc(100vh-2rem))] w-[360px] select-none flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
|
||||
>
|
||||
{panelContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { History, KeyRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
|
||||
import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlaygroundSidebarProps {
|
||||
workspaceId: number | string;
|
||||
}
|
||||
|
||||
function PlaygroundNavLink({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
isActive,
|
||||
indented = false,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: PlatformIcon;
|
||||
isActive: boolean;
|
||||
indented?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
|
||||
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
indented && "pl-8",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const base = `/dashboard/${workspaceId}/playground`;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-[240px] flex-col bg-panel text-sidebar-foreground overflow-hidden select-none">
|
||||
<div className="flex h-12 shrink-0 items-center px-4">
|
||||
<span className="text-sm font-semibold">API Playground</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pb-1.5 after:mx-3 after:mt-1.5 after:block after:h-px after:bg-border">
|
||||
<PlaygroundNavLink
|
||||
href={`${base}/runs`}
|
||||
label="Runs"
|
||||
icon={History}
|
||||
isActive={pathname === `${base}/runs`}
|
||||
/>
|
||||
<PlaygroundNavLink
|
||||
href={`${base}/api-keys`}
|
||||
label="API Keys"
|
||||
icon={KeyRound}
|
||||
isActive={pathname === `${base}/api-keys`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full min-h-0 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent pb-2">
|
||||
{PLAYGROUND_PLATFORMS.map((platform) => (
|
||||
<div key={platform.id} className="flex flex-col gap-0.5 pt-2">
|
||||
<div className="flex items-center gap-2 pl-4 pr-2.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
<platform.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{platform.label}</span>
|
||||
</div>
|
||||
{platform.verbs.map((verb) => {
|
||||
const href = `${base}/${platform.id}/${verb.verb}`;
|
||||
return (
|
||||
<PlaygroundNavLink
|
||||
key={verb.name}
|
||||
href={href}
|
||||
label={verb.label}
|
||||
isActive={pathname === href}
|
||||
indented
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 py-1.5 before:mx-3 before:mb-1.5 before:block before:h-px before:bg-border">
|
||||
<ConnectAgentDialog />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -18,7 +18,7 @@ import { ChatListItem } from "./ChatListItem";
|
|||
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
import { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
import { SidebarButton, SidebarButtonBadge } from "./SidebarButton";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
|
|
@ -46,21 +46,6 @@ function ChatListSkeletonRows() {
|
|||
);
|
||||
}
|
||||
|
||||
function CollapsedInboxIcon({ item }: { item: NavItem }) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{typeof item.badge === "string" ? (
|
||||
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: Workspace | null;
|
||||
isCollapsed?: boolean;
|
||||
|
|
@ -138,10 +123,9 @@ export function Sidebar({
|
|||
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
|
||||
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
|
||||
|
||||
// Inbox, Automations, and Artifacts are rendered explicitly right below
|
||||
// Automations, Artifacts, and Playground are rendered explicitly right below
|
||||
// New Chat. Pull them out of the nav items list so they don't also appear
|
||||
// in the bottom NavSection. Documents is embedded below Recents.
|
||||
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
|
||||
const automationsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/automations")),
|
||||
[navItems]
|
||||
|
|
@ -158,7 +142,6 @@ export function Sidebar({
|
|||
() =>
|
||||
navItems.filter(
|
||||
(item) =>
|
||||
item.url !== "#inbox" &&
|
||||
!item.url.endsWith("/automations") &&
|
||||
!item.url.endsWith("/artifacts") &&
|
||||
!item.url.endsWith("/playground")
|
||||
|
|
@ -218,7 +201,7 @@ export function Sidebar({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-3 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
|
||||
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
|
||||
isSidebarNavScrolled ? "after:opacity-100" : "after:opacity-0"
|
||||
)}
|
||||
>
|
||||
|
|
@ -235,23 +218,6 @@ export function Sidebar({
|
|||
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
|
||||
{inboxItem && (
|
||||
<SidebarButton
|
||||
icon={inboxItem.icon}
|
||||
label={inboxItem.title}
|
||||
onClick={() => onNavItemClick?.(inboxItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={inboxItem.isActive}
|
||||
badge={inboxItem.badge}
|
||||
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
|
||||
tooltipContent={isCollapsed ? inboxItem.title : undefined}
|
||||
buttonProps={
|
||||
{
|
||||
"data-joyride": "inbox-sidebar",
|
||||
} as React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{automationsItem && (
|
||||
<SidebarButton
|
||||
icon={automationsItem.icon}
|
||||
|
|
@ -279,6 +245,7 @@ export function Sidebar({
|
|||
onClick={() => onNavItemClick?.(playgroundItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={playgroundItem.isActive}
|
||||
badge={<SidebarButtonBadge>New</SidebarButtonBadge>}
|
||||
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -356,10 +323,16 @@ export function Sidebar({
|
|||
/>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="shrink-0 py-1.5">
|
||||
<ConnectAgentDialog className="w-[calc(100%-1rem)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarUsageFooter
|
||||
pageUsage={pageUsage}
|
||||
isCollapsed={isCollapsed}
|
||||
hasNavSectionAbove={footerNavItems.length > 0}
|
||||
hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
|
|
@ -436,29 +409,25 @@ function SidebarUsageFooter({
|
|||
return (
|
||||
<div className={containerClass}>
|
||||
<CreditBalanceDisplay />
|
||||
<div className="space-y-0.5">
|
||||
<div className="relative grid grid-cols-2 before:absolute before:inset-y-1 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-border">
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/earn-credits`}
|
||||
onClick={onNavigate}
|
||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
|
||||
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<Zap className="h-3 w-3 shrink-0" />
|
||||
Earn credits
|
||||
</span>
|
||||
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||
<Zap className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">Earn</span>
|
||||
<SidebarButtonBadge className="h-4 px-1 text-[10px] bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
FREE
|
||||
</Badge>
|
||||
</SidebarButtonBadge>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/buy-more`}
|
||||
onClick={onNavigate}
|
||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
|
||||
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
Buy credits
|
||||
</span>
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">Buy</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -28,6 +29,26 @@ const baseClassName = cn(
|
|||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
);
|
||||
|
||||
export function SidebarButtonBadge({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
|
@ -73,17 +94,19 @@ export function SidebarButton({
|
|||
isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2"
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{label}</span>
|
||||
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
|
||||
{!isCollapsed && badge && typeof badge === "string" ? (
|
||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{!isCollapsed && trailingContent}
|
||||
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
|
||||
{!isCollapsed && badge && typeof badge === "string" ? (
|
||||
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{collapsedOverlay && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
|
||||
|
||||
interface SidebarSlideOutPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
ariaLabel: string;
|
||||
width?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable slide-out panel that extends from the sidebar.
|
||||
*
|
||||
* Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
|
||||
* content with a blur backdrop. Does not push/shrink the main content.
|
||||
*
|
||||
* Mobile: full-width absolute overlay (unchanged).
|
||||
*/
|
||||
export function SidebarSlideOutPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
ariaLabel,
|
||||
width = 360,
|
||||
children,
|
||||
}: SidebarSlideOutPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
bumpSlideoutOpenedTick((tick) => tick + 1);
|
||||
}
|
||||
}, [open, bumpSlideoutOpenedTick]);
|
||||
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, handleEscape]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<>
|
||||
{/* Blur backdrop covering the main content area (right of sidebar) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
|
||||
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute z-20 overflow-hidden"
|
||||
style={{ left: "100%", top: -1, bottom: -1 }}
|
||||
>
|
||||
<div
|
||||
style={{ width }}
|
||||
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border shadow-xl"
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
ExternalLink,
|
||||
|
|
@ -16,8 +17,10 @@ import {
|
|||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import type React from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -63,6 +66,8 @@ const LEARN_MORE_LINKS = [
|
|||
{ key: "github" as const, href: "https://github.com/MODSetter/SurfSense" },
|
||||
];
|
||||
|
||||
type MobileProfileSubmenu = "theme" | "language" | "learn_more";
|
||||
|
||||
interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
onUserSettings?: () => void;
|
||||
|
|
@ -72,6 +77,7 @@ interface SidebarUserProfileProps {
|
|||
isCollapsed?: boolean;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
topContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
function formatAnnouncementCount(count: number): string {
|
||||
|
|
@ -134,6 +140,7 @@ export function SidebarUserProfile({
|
|||
isCollapsed = false,
|
||||
theme,
|
||||
setTheme,
|
||||
topContent,
|
||||
}: SidebarUserProfileProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { locale, setLocale } = useLocaleContext();
|
||||
|
|
@ -141,12 +148,14 @@ export function SidebarUserProfile({
|
|||
const isDesktopViewport = useMediaQuery("(min-width: 768px)");
|
||||
const { os, primary, isMobileOS } = usePrimaryDownload();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [mobileSubmenu, setMobileSubmenu] = useState<MobileProfileSubmenu | null>(null);
|
||||
const bgColor = getUserAvatarColor(user.email);
|
||||
const initials = getUserInitials(user.email);
|
||||
const displayName = user.name || user.email.split("@")[0];
|
||||
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
|
||||
const downloadLabel = t("download_for_os", { os });
|
||||
const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport;
|
||||
const useMobileSubmenus = !isDesktopViewport;
|
||||
|
||||
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
|
||||
setLocale(newLocale);
|
||||
|
|
@ -166,11 +175,248 @@ export function SidebarUserProfile({
|
|||
}
|
||||
};
|
||||
|
||||
const submenuTriggerClassName = "cursor-default";
|
||||
const drawerItemClassName = cn(
|
||||
"flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
);
|
||||
const drawerSeparatorClassName = "mx-3 h-px bg-popover-border";
|
||||
|
||||
const renderThemeSubmenu = () => {
|
||||
if (!setTheme) return null;
|
||||
|
||||
if (useMobileSubmenus) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={submenuTriggerClassName}
|
||||
onSelect={() => setMobileSubmenu("theme")}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="flex-1">{t("theme")}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{THEMES.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={themeOption.value}
|
||||
onClick={() => handleThemeChange(themeOption.value)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLanguageSubmenu = () => {
|
||||
if (useMobileSubmenus) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={submenuTriggerClassName}
|
||||
onSelect={() => setMobileSubmenu("language")}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
<span className="flex-1">{t("language")}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{LANGUAGES.map((language) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLearnMoreSubmenu = () => {
|
||||
if (useMobileSubmenus) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={submenuTriggerClassName}
|
||||
onSelect={() => setMobileSubmenu("learn_more")}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="flex-1">{t("learn_more")}</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Info className="h-4 w-4" />
|
||||
{t("learn_more")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="min-w-[180px] gap-1">
|
||||
{LEARN_MORE_LINKS.map((link) => (
|
||||
<DropdownMenuItem key={link.key} asChild>
|
||||
<a href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
<span className="flex-1">{t(link.key)}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
const mobileSubmenuDrawer = (
|
||||
<Drawer
|
||||
open={mobileSubmenu !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setMobileSubmenu(null);
|
||||
}}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent
|
||||
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
|
||||
{mobileSubmenu === "theme"
|
||||
? t("theme")
|
||||
: mobileSubmenu === "language"
|
||||
? t("language")
|
||||
: t("learn_more")}
|
||||
</DrawerTitle>
|
||||
<div className="space-y-1 px-4 pb-6 pt-1">
|
||||
{mobileSubmenu === "theme" &&
|
||||
setTheme &&
|
||||
THEMES.map((themeOption, index) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<Fragment key={themeOption.value}>
|
||||
{index > 0 && <div className={drawerSeparatorClassName} />}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(drawerItemClassName, isSelected && "text-primary")}
|
||||
onClick={() => {
|
||||
handleThemeChange(themeOption.value);
|
||||
setMobileSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{mobileSubmenu === "language" &&
|
||||
LANGUAGES.map((language, index) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<Fragment key={language.code}>
|
||||
{index > 0 && <div className={drawerSeparatorClassName} />}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(drawerItemClassName, isSelected && "text-primary")}
|
||||
onClick={() => {
|
||||
handleLanguageChange(language.code);
|
||||
setMobileSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<span className="text-base">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{mobileSubmenu === "learn_more" && (
|
||||
<>
|
||||
{LEARN_MORE_LINKS.map((link, index) => (
|
||||
<Fragment key={link.key}>
|
||||
{index > 0 && <div className={drawerSeparatorClassName} />}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={drawerItemClassName}
|
||||
onClick={() => setMobileSubmenu(null)}
|
||||
>
|
||||
<span className="flex-1">{t(link.key)}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</Fragment>
|
||||
))}
|
||||
<p className="select-none px-3 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
// Collapsed view - just show avatar with dropdown
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-full border-t px-1.5 py-2">
|
||||
<div className="relative w-full px-1.5 py-2 before:absolute before:inset-x-1.5 before:top-0 before:h-px before:bg-border">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{topContent}
|
||||
{showDownloadCta && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -247,89 +493,11 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{THEMES.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={themeOption.value}
|
||||
onClick={() => handleThemeChange(themeOption.value)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{renderThemeSubmenu()}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{LANGUAGES.map((language) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
{renderLanguageSubmenu()}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Info className="h-4 w-4" />
|
||||
{t("learn_more")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="min-w-[180px] gap-1">
|
||||
{LEARN_MORE_LINKS.map((link) => (
|
||||
<DropdownMenuItem key={link.key} asChild>
|
||||
<a href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
<span className="flex-1">{t(link.key)}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
{renderLearnMoreSubmenu()}
|
||||
|
||||
{!isDesktop && !isMobileOS && (
|
||||
<DropdownMenuItem asChild className="font-medium">
|
||||
|
|
@ -352,6 +520,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{mobileSubmenuDrawer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -429,89 +598,11 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{THEMES.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
const isSelected = theme === themeOption.value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={themeOption.value}
|
||||
onClick={() => handleThemeChange(themeOption.value)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{renderThemeSubmenu()}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="gap-1">
|
||||
{LANGUAGES.map((language) => {
|
||||
const isSelected = locale === language.code;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
{renderLanguageSubmenu()}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Info className="h-4 w-4" />
|
||||
{t("learn_more")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="min-w-[180px] gap-1">
|
||||
{LEARN_MORE_LINKS.map((link) => (
|
||||
<DropdownMenuItem key={link.key} asChild>
|
||||
<a href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
<span className="flex-1">{t(link.key)}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
{renderLearnMoreSubmenu()}
|
||||
|
||||
{!isDesktop && (
|
||||
<DropdownMenuItem asChild className="font-medium">
|
||||
|
|
@ -530,6 +621,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{mobileSubmenuDrawer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
|
|||
export { ChatListItem } from "./ChatListItem";
|
||||
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { PlaygroundSidebar } from "./PlaygroundSidebar";
|
||||
export { NotificationsDropdown } from "./NotificationsDropdown";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
export { SidebarHeader } from "./SidebarHeader";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Cable } from "lucide-react";
|
||||
import { SidebarButtonBadge } from "@/components/layout/ui/sidebar/SidebarButton";
|
||||
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -28,8 +28,18 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
|
|||
className
|
||||
)}
|
||||
>
|
||||
<Cable className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">Connect your AI Agent</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-3.5 shrink-0 bg-current"
|
||||
style={{
|
||||
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate">Connect your agent</span>
|
||||
<SidebarButtonBadge>New</SidebarButtonBadge>
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const demoPlans = [
|
|||
"Premium models like GPT-5.5, Claude Sonnet 5, Gemini 3.1 Pro billed at provider cost",
|
||||
"Scheduled and event-triggered agents for briefs, alerts, and monitoring",
|
||||
"Write results back to Notion, Slack, Linear, and Jira",
|
||||
"Top up any amount, $1 buys $1 of credit, optional auto-reload",
|
||||
"Add credit any time. $1 buys $1 of credit, with optional automatic refills",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
description: "",
|
||||
|
|
@ -95,7 +95,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "How does Pay As You Go work?",
|
||||
answer:
|
||||
"There is no monthly subscription. Start with $5 of free credit, and when you need more, top up any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable auto-reload to top up automatically when your balance runs low, and turn it off any time.",
|
||||
"There is no monthly subscription. Start with $5 of free credit, and when you need more, add any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable automatic refills when your balance runs low, and turn them off any time."
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of credit?",
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
|
|
@ -69,7 +69,7 @@ export function AutoReloadSettings() {
|
|||
const setupResult = searchParams.get("auto_reload_setup");
|
||||
if (!setupResult) return;
|
||||
if (setupResult === "success") {
|
||||
toast.success("Card saved. You can now enable auto-reload.");
|
||||
toast.success("Card saved. You can now enable top-ups.");
|
||||
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
|
||||
} else if (setupResult === "cancel") {
|
||||
toast.info("Card setup canceled.");
|
||||
|
|
@ -92,19 +92,19 @@ export function AutoReloadSettings() {
|
|||
mutationFn: stripeApiService.updateAutoReloadSettings,
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(["auto-reload-settings"], updated);
|
||||
toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
|
||||
toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof AppError && error.message) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
toast.error("Couldn't save auto-reload settings. Please try again.");
|
||||
toast.error("Couldn't save top-up settings. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
// Render nothing while loading (avoids a spinner flash on pages where the
|
||||
// feature flag turns out to be off) and when auto-reload is disabled
|
||||
// feature flag turns out to be off) and when top-ups are disabled
|
||||
// server-side.
|
||||
if (isLoading || !settings || !settings.feature_enabled) {
|
||||
return null;
|
||||
|
|
@ -131,7 +131,7 @@ export function AutoReloadSettings() {
|
|||
return;
|
||||
}
|
||||
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
|
||||
toast.error(`Reload amount must be at least $${minAmountDollars}.`);
|
||||
toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -142,135 +142,168 @@ export function AutoReloadSettings() {
|
|||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4 text-amber-500" />
|
||||
Auto-reload
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Automatically top up your credit balance when it drops below a threshold, using a saved
|
||||
card. Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
const addCardButton = (
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Add a card"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasCard) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{settings.failed_at && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Last auto-reload failed</AlertTitle>
|
||||
<AlertTitle>Last top-up failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your saved card was declined and auto-reload was turned off. Update your card and
|
||||
re-enable it below to keep topping up automatically.
|
||||
Your saved card was declined and top-ups were turned off. Update your card and
|
||||
re-enable top-ups below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!hasCard ? (
|
||||
<div className="flex flex-col items-start gap-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Add a card to enable automatic top-ups.</span>
|
||||
<div className="space-y-6">
|
||||
<Alert className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Automatically top up your credit balance when it drops below a threshold, using a
|
||||
saved card. Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setupMutation.mutate()} disabled={setupMutation.isPending}>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Add a card"
|
||||
)}
|
||||
</Button>
|
||||
{addCardButton}
|
||||
</Alert>
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{settings.failed_at && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Last top-up failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your saved card was declined and top-ups were turned off. Update your card and
|
||||
re-enable top-ups below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold tracking-tight">Automatic top-ups</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-reload-toggle" className="text-sm font-medium">
|
||||
Enable auto-reload
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Charge your saved card when the balance gets low.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auto-reload-toggle" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Update card"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auto-reload-threshold" className="text-xs">
|
||||
When balance falls below
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="auto-reload-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={thresholdInput}
|
||||
onChange={(e) => setThresholdInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auto-reload-amount" className="text-xs">
|
||||
Add this much credit
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="auto-reload-amount"
|
||||
type="number"
|
||||
min={minAmountDollars}
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="top-ups-toggle" className="text-sm font-medium">
|
||||
Enable top-ups
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Charge your saved card when the balance gets low.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="top-ups-toggle" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
<CreditCard className="h-3.5 w-3.5" />
|
||||
Update card
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="top-ups-threshold" className="text-xs">
|
||||
When balance falls below
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="top-ups-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={thresholdInput}
|
||||
onChange={(e) => setThresholdInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="top-ups-amount" className="text-xs">
|
||||
Add this much credit
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="top-ups-amount"
|
||||
type="number"
|
||||
min={minAmountDollars}
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="w-full sm:w-auto" onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-popover data-[state=active]:text-popover-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const scraperPricingMeter = z.object({
|
|||
export const scraperCapability = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
docs_url: z.string().nullable().optional(),
|
||||
input_schema: z.record(z.string(), z.unknown()),
|
||||
// Optional so a backend that predates output schemas degrades to just not
|
||||
// showing the output-schema block instead of failing the whole fetch.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||
|
|
@ -46,8 +46,15 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
|
|||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-center">
|
||||
<p className="font-medium text-foreground">No artifacts yet</p>
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Boxes className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Artifacts collect the reports, resumes, podcasts, presentations, and images SurfSense
|
||||
creates for this workspace. Generated deliverables from your chats will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function useInbox(
|
|||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const olderUnreadOffsetRef = useRef<number | null>(null);
|
||||
|
|
@ -71,6 +72,7 @@ export function useInbox(
|
|||
setLoading(true);
|
||||
setInboxItems([]);
|
||||
setHasMore(false);
|
||||
setTotalCount(0);
|
||||
initialLoadDoneRef.current = false;
|
||||
olderUnreadOffsetRef.current = null;
|
||||
apiUnreadTotalRef.current = 0;
|
||||
|
|
@ -97,6 +99,7 @@ export function useInbox(
|
|||
if (cancelled) return;
|
||||
|
||||
setInboxItems(notificationsResponse.items);
|
||||
setTotalCount(notificationsResponse.total);
|
||||
setHasMore(notificationsResponse.has_more);
|
||||
setUnreadCount(unreadResponse.total_unread);
|
||||
apiUnreadTotalRef.current = unreadResponse.total_unread;
|
||||
|
|
@ -222,6 +225,7 @@ export function useInbox(
|
|||
const deduped = newItems.filter((d) => !existingIds.has(d.id));
|
||||
return [...prev, ...deduped];
|
||||
});
|
||||
setTotalCount(response.total);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
console.error(`[useInbox:${category}] Load more failed:`, err);
|
||||
|
|
@ -299,6 +303,7 @@ export function useInbox(
|
|||
return {
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
totalCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -1 +1,57 @@
|
|||
<svg role="img" viewBox="0 0 24 24" fill="#FF4500" xmlns="http://www.w3.org/2000/svg"><title>Reddit</title><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="_1O4jTk-dZ-VIxsCuYB6OR8" viewBox="0 0 216 216">
|
||||
<defs>
|
||||
<radialGradient id="snoo-radial-gragient" cx="169.75" cy="92.19" r="50.98" fx="169.75" fy="92.19" gradientTransform="matrix(1 0 0 .87 0 11.64)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#feffff"/>
|
||||
<stop offset=".4" stop-color="#feffff"/>
|
||||
<stop offset=".51" stop-color="#f9fcfc"/>
|
||||
<stop offset=".62" stop-color="#edf3f5"/>
|
||||
<stop offset=".7" stop-color="#dee9ec"/>
|
||||
<stop offset=".72" stop-color="#d8e4e8"/>
|
||||
<stop offset=".76" stop-color="#ccd8df"/>
|
||||
<stop offset=".8" stop-color="#c8d5dd"/>
|
||||
<stop offset=".83" stop-color="#ccd6de"/>
|
||||
<stop offset=".85" stop-color="#d8dbe2"/>
|
||||
<stop offset=".88" stop-color="#ede3e9"/>
|
||||
<stop offset=".9" stop-color="#ffebef"/>
|
||||
</radialGradient>
|
||||
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-2" cx="47.31" r="50.98" fx="47.31"/>
|
||||
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-3" cx="109.61" cy="85.59" r="153.78" fx="109.61" fy="85.59" gradientTransform="matrix(1 0 0 .7 0 25.56)"/>
|
||||
<radialGradient id="snoo-radial-gragient-4" cx="-6.01" cy="64.68" r="12.85" fx="-6.01" fy="64.68" gradientTransform="matrix(1.07 0 0 1.55 81.08 27.26)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f60"/>
|
||||
<stop offset=".5" stop-color="#ff4500"/>
|
||||
<stop offset=".7" stop-color="#fc4301"/>
|
||||
<stop offset=".82" stop-color="#f43f07"/>
|
||||
<stop offset=".92" stop-color="#e53812"/>
|
||||
<stop offset="1" stop-color="#d4301f"/>
|
||||
</radialGradient>
|
||||
<radialGradient xlink:href="#snoo-radial-gragient-4" id="snoo-radial-gragient-5" cx="-73.55" cy="64.68" r="12.85" fx="-73.55" fy="64.68" gradientTransform="matrix(-1.07 0 0 1.55 62.87 27.26)"/>
|
||||
<radialGradient id="snoo-radial-gragient-6" cx="107.93" cy="166.96" r="45.3" fx="107.93" fy="166.96" gradientTransform="matrix(1 0 0 .66 0 57.4)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#172e35"/>
|
||||
<stop offset=".29" stop-color="#0e1c21"/>
|
||||
<stop offset=".73" stop-color="#030708"/>
|
||||
<stop offset="1"/>
|
||||
</radialGradient>
|
||||
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-7" cx="147.88" cy="32.94" r="39.77" fx="147.88" fy="32.94" gradientTransform="matrix(1 0 0 .98 0 .54)"/>
|
||||
<radialGradient id="snoo-radial-gragient-8" cx="131.31" cy="73.08" r="32.6" fx="131.31" fy="73.08" gradientUnits="userSpaceOnUse">
|
||||
<stop offset=".48" stop-color="#7a9299"/>
|
||||
<stop offset=".67" stop-color="#172e35"/>
|
||||
<stop offset=".75"/>
|
||||
<stop offset=".82" stop-color="#172e35"/>
|
||||
</radialGradient>
|
||||
<style>
|
||||
.snoo-cls-11{stroke-width:0;fill:#ffc49c}
|
||||
</style>
|
||||
</defs>
|
||||
<path fill="#ff4500" stroke-width="0" d="M108 0C48.35 0 0 48.35 0 108c0 29.82 12.09 56.82 31.63 76.37l-20.57 20.57C6.98 209.02 9.87 216 15.64 216H108c59.65 0 108-48.35 108-108S167.65 0 108 0Z"/>
|
||||
<circle cx="169.22" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient)" stroke-width="0"/>
|
||||
<circle cx="46.78" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient-2)" stroke-width="0"/>
|
||||
<ellipse cx="108.06" cy="128.64" fill="url(#snoo-radial-gragient-3)" stroke-width="0" rx="72" ry="54"/>
|
||||
<path fill="url(#snoo-radial-gragient-4)" stroke-width="0" d="M86.78 123.48c-.42 9.08-6.49 12.38-13.56 12.38s-12.46-4.93-12.04-14.01c.42-9.08 6.49-15.02 13.56-15.02s12.46 7.58 12.04 16.66Z"/>
|
||||
<path fill="url(#snoo-radial-gragient-5)" stroke-width="0" d="M129.35 123.48c.42 9.08 6.49 12.38 13.56 12.38s12.46-4.93 12.04-14.01c-.42-9.08-6.49-15.02-13.56-15.02s-12.46 7.58-12.04 16.66Z"/>
|
||||
<ellipse cx="79.63" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
|
||||
<ellipse cx="146.21" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
|
||||
<path fill="url(#snoo-radial-gragient-6)" stroke-width="0" d="M108.06 142.92c-8.76 0-17.16.43-24.92 1.22-1.33.13-2.17 1.51-1.65 2.74 4.35 10.39 14.61 17.69 26.57 17.69s22.23-7.3 26.57-17.69c.52-1.23-.33-2.61-1.65-2.74-7.77-.79-16.16-1.22-24.92-1.22Z"/>
|
||||
<circle cx="147.49" cy="49.43" r="17.87" fill="url(#snoo-radial-gragient-7)" stroke-width="0"/>
|
||||
<path fill="url(#snoo-radial-gragient-8)" stroke-width="0" d="M107.8 76.92c-2.14 0-3.87-.89-3.87-2.27 0-16.01 13.03-29.04 29.04-29.04 2.14 0 3.87 1.73 3.87 3.87s-1.73 3.87-3.87 3.87c-11.74 0-21.29 9.55-21.29 21.29 0 1.38-1.73 2.27-3.87 2.27Z"/>
|
||||
<path fill="#842123" stroke-width="0" d="M62.82 122.65c.39-8.56 6.08-14.16 12.69-14.16 6.26 0 11.1 6.39 11.28 14.33.17-8.88-5.13-15.99-12.05-15.99s-13.14 6.05-13.56 15.2c-.42 9.15 4.97 13.83 12.04 13.83h.52c-6.44-.16-11.3-4.79-10.91-13.2Zm90.48 0c-.39-8.56-6.08-14.16-12.69-14.16-6.26 0-11.1 6.39-11.28 14.33-.17-8.88 5.13-15.99 12.05-15.99 7.07 0 13.14 6.05 13.56 15.2.42 9.15-4.97 13.83-12.04 13.83h-.52c6.44-.16 11.3-4.79 10.91-13.2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
|
@ -1 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.25"/><path d="M2.75 12h18.5"/><ellipse cx="12" cy="12" rx="4.1" ry="9.25"/><path d="M5 6.4h14M5 17.6h14"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<g fill="none" stroke-width="3">
|
||||
<path fill="#8fbffa" d="M3 24a21 21 0 1 0 42 0a21 21 0 1 0-42 0" />
|
||||
<path stroke="#2859c5" stroke-linejoin="round" d="M3 24a21 21 0 1 0 42 0a21 21 0 1 0-42 0" />
|
||||
<path stroke="#2859c5" stroke-linejoin="round" d="M15 24a9 21 0 1 1 18 0a9 21 0 1 1-18 0" />
|
||||
<path stroke="#2859c5" stroke-linecap="round" stroke-linejoin="round" d="M4.5 31h39m-39-14h39" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 469 B |
Loading…
Add table
Add a link
Reference in a new issue