diff --git a/surfsense_backend/app/capabilities/core/access/rest.py b/surfsense_backend/app/capabilities/core/access/rest.py index 328a7c5a1..2047b21ae 100644 --- a/surfsense_backend/app/capabilities/core/access/rest.py +++ b/surfsense_backend/app/capabilities/core/access/rest.py @@ -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(), ), diff --git a/surfsense_backend/app/capabilities/core/types.py b/surfsense_backend/app/capabilities/core/types.py index ec44d29aa..c87601832 100644 --- a/surfsense_backend/app/capabilities/core/types.py +++ b/surfsense_backend/app/capabilities/core/types.py @@ -62,3 +62,4 @@ class Capability: output_schema: type[BaseModel] executor: Executor billing_unit: BillingUnit | None + docs_url: str | None = None diff --git a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py index e96c6f906..5366194d5 100644 --- a/surfsense_backend/app/capabilities/google_maps/reviews/definition.py +++ b/surfsense_backend/app/capabilities/google_maps/reviews/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py index 035b7e00d..db117a2ac 100644 --- a/surfsense_backend/app/capabilities/google_maps/scrape/definition.py +++ b/surfsense_backend/app/capabilities/google_maps/scrape/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/google_search/scrape/definition.py b/surfsense_backend/app/capabilities/google_search/scrape/definition.py index 2f2c9de03..2f62847d1 100644 --- a/surfsense_backend/app/capabilities/google_search/scrape/definition.py +++ b/surfsense_backend/app/capabilities/google_search/scrape/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/reddit/scrape/definition.py b/surfsense_backend/app/capabilities/reddit/scrape/definition.py index 01fb6db5f..fe00a77be 100644 --- a/surfsense_backend/app/capabilities/reddit/scrape/definition.py +++ b/surfsense_backend/app/capabilities/reddit/scrape/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/web/crawl/definition.py b/surfsense_backend/app/capabilities/web/crawl/definition.py index cd3db2306..362f4bcd0 100644 --- a/surfsense_backend/app/capabilities/web/crawl/definition.py +++ b/surfsense_backend/app/capabilities/web/crawl/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/youtube/comments/definition.py b/surfsense_backend/app/capabilities/youtube/comments/definition.py index ec689a23c..c22145547 100644 --- a/surfsense_backend/app/capabilities/youtube/comments/definition.py +++ b/surfsense_backend/app/capabilities/youtube/comments/definition.py @@ -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) diff --git a/surfsense_backend/app/capabilities/youtube/scrape/definition.py b/surfsense_backend/app/capabilities/youtube/scrape/definition.py index 43874e254..d281ea7d6 100644 --- a/surfsense_backend/app/capabilities/youtube/scrape/definition.py +++ b/surfsense_backend/app/capabilities/youtube/scrape/definition.py @@ -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) diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx index 0dbd12818..2df3ec58e 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/api-keys/page.tsx @@ -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 ( -
- -
- ); + redirect(`/dashboard/${workspace_id}/user-settings/api-key`); } diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx deleted file mode 100644 index e3df313f0..000000000 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-keys-section.tsx +++ /dev/null @@ -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 ( -
-
-

API Keys

-

- Enable API access for this workspace and manage the keys that use it. -

-
- -
-
- -

- Allow API keys to access this workspace. - {!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."} -

-
- {isLoading ? ( - - ) : ( - - )} -
- - -
- ); -} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx index acdcddd6c..beed5d525 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx @@ -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 ? : } - {copied ? "Copied" : "Copy"} ); } @@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record JSON.stringify(schema, null, 2), [schema]); return (
- - {title} + + {title} +
@@ -90,11 +91,7 @@ export function ApiReference({

API reference

- Call this API from your own project. Create a key under{" "} - API Keys (and enable API access for - this workspace), then send it as a{" "} - Authorization: Bearer{" "} - header. + Create an API key, enable API access for this workspace, then use the examples below to call this endpoint.

diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx index 606548e14..e692734d3 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/output-viewer.tsx @@ -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 (
-
- {items && ( - - )} - -
+ setView(value as "table" | "json")}> + + {items && Table} + JSON + +
{items && items.length > 0 && (
@@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa {truncated && (

- 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.

)} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx index 741ed291f..7e9d45373 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-index.tsx @@ -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 (
-
-

API Playground

-

- Manually run SurfSense's platform-native APIs and inspect their output. Every run is - captured under Runs. -

-
+ + + +

+ Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "} + + create an API key + + . +

+
+
-

Runs

+

API Runs

See every API run in this workspace

- -
- -
-

API Keys

-

- Enable workspace access and manage keys -

-
-
- -
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx index 3b76a7d09..f62ffc312 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx @@ -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 ( -
-

Insufficient credits

-

You don't have enough credits to run this API.

- -
- ); + 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 ( -
- - {message} -
+ ); } @@ -110,6 +127,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn const [values, setValues] = useState>({}); const run = useRunStream(workspaceId); const isRunning = run.status === "running"; + const previousStatusRef = useRef(run.status); + const notifiedRunRef = useRef(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 (
-
+
+ {capability.description && ( + + + +

+ {capability.description} + {capability.docs_url ? ( + <> + {" "} + + Read docs + + {" "}for more info. + + ) : null} +

+
+
+ )} +
-
-

- {catalogVerb.label} · {platform} -

- {capability.description && ( -

{capability.description}

- )} - - POST /workspaces/{workspaceId}/scrapers/{platform}/{verb} - -
- +
+ +
+ Pricing: {formatPricing(capability.pricing)}
-
- {isRunning && ( - )}
- {run.status === "error" && }
diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx index f47bd4572..6368a1f76 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/runs-table.tsx @@ -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 (
-
-

Runs

-

- Every platform-native API run in this workspace — from the playground, API keys, and - agents. Newest first. -

-
+ + + + View all API runs for this workspace, including runs from the playground, API keys, and agents. + +
setSearchQuery(e.target.value)} - className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none" - /> - {searchQuery && ( - - )} -
-
- - { - const tab = value as InboxTab; - setActiveTab(tab); - if (tab !== "status" && activeFilter === "errors") { - setActiveFilter("all"); - } - }} - className="shrink-0 mx-3 mt-1.5" - > - - - - - {t("comments") || "Comments"} - - {formatInboxCount(comments.unreadCount)} - - - - - - - {t("status") || "Status"} - - {formatInboxCount(status.unreadCount)} - - - - - - -
- {isLoading ? ( -
- {activeTab === "comments" - ? [85, 60, 90, 70, 50, 75].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [75, 90, 55, 80, 65, 85].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- ))} -
- ) : filteredItems.length > 0 ? ( -
- {filteredItems.map((item, index) => { - const isMarkingAsRead = markingAsReadId === item.id; - const isPrefetchTrigger = - !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; - - return ( -
- {activeTab === "status" ? ( - - - - - -

{item.title}

-

- {convertRenderedToDisplay(item.message)} -

-
-
- ) : ( - - )} - -
- - {formatTime(item.created_at)} - - {!item.read && } -
-
- ); - })} - {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && ( -
- )} - {activeSource.loadingMore && - (activeTab === "comments" - ? [80, 60, 90].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [70, 85, 55].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- )))} -
- ) : isSearchMode ? ( -
- -

- {t("no_results_found") || "No results found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- {activeTab === "comments" ? ( - - ) : ( - - )} -

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

-
- )} -
- - ); -} - -export function InboxSidebar({ - open, - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarProps) { - const t = useTranslations("sidebar"); - - return ( - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index f9901d7cb..1d02b56f4 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -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 ? ( + onOpenChange(false)} + /> + ) : undefined + } />
{/* Sidebar Content - right side */} -
+
void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; +} + +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("all"); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + const [markingAllAsRead, setMarkingAllAsRead] = useState(false); + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(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 = ( + + ); + + const panelContent = ( + <> +
+
+

Notifications

+
+ +
+ +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.value; + return ( + + ); + })} +
+ +
+ {isLoading ? ( +
+ {[82, 64, 74].map((width) => ( +
+
+ + +
+
+ ))} +
+ ) : items.length > 0 ? ( +
+ {items.map((item) => { + const isMarkingAsRead = markingAsReadId === item.id; + return ( + + ); + })} + {hasMore ? ( +
+ {isLoadingMore ? : null} +
+ ) : null} +
+ ) : ( +
+

{emptyStateCopy.title}

+

{emptyStateCopy.description}

+ {hasMore ? ( + + ) : null} +
+ )} +
+ + ); + + if (isMobile) { + return ( + + {triggerButton} + + + Notifications +
{panelContent}
+
+
+ ); + } + + return ( + + + + {triggerButton} + + + Notifications + + + + {panelContent} + + + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx deleted file mode 100644 index 1c2f96212..000000000 --- a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx +++ /dev/null @@ -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 ( - - {Icon ? : null} - {label} - - ); -} - -export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) { - const pathname = usePathname(); - const base = `/dashboard/${workspaceId}/playground`; - - return ( -
-
- API Playground -
- -
- - -
- -
- {PLAYGROUND_PLATFORMS.map((platform) => ( -
-
- - {platform.label} -
- {platform.verbs.map((verb) => { - const href = `${base}/${platform.id}/${verb.verb}`; - return ( - - ); - })} -
- ))} -
- -
- -
-
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 35746acf1..483d3a43d 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -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 ( - - - {typeof item.badge === "string" ? ( - - {item.badge} - - ) : null} - - ); -} - interface SidebarProps { workspace: Workspace | null; isCollapsed?: boolean; @@ -138,10 +123,9 @@ export function Sidebar({ const [openDropdownChatId, setOpenDropdownChatId] = useState(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({
@@ -235,23 +218,6 @@ export function Sidebar({ onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} >
- {inboxItem && ( - onNavItemClick?.(inboxItem)} - isCollapsed={isCollapsed} - isActive={inboxItem.isActive} - badge={inboxItem.badge} - collapsedIconNode={} - tooltipContent={isCollapsed ? inboxItem.title : undefined} - buttonProps={ - { - "data-joyride": "inbox-sidebar", - } as React.ButtonHTMLAttributes - } - /> - )} {automationsItem && ( onNavItemClick?.(playgroundItem)} isCollapsed={isCollapsed} isActive={playgroundItem.isActive} + badge={New} tooltipContent={isCollapsed ? playgroundItem.title : undefined} /> )} @@ -356,10 +323,16 @@ export function Sidebar({ /> )} + {!isCollapsed && ( +
+ +
+ )} + 0} + hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed} onNavigate={onNavigate} /> @@ -436,29 +409,25 @@ function SidebarUsageFooter({ return (
-
+
- - - Earn credits - - + + Earn + FREE - + - - - Buy credits - + + Buy
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx index cc7f83e1d..1557253a9 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx @@ -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 ( + + {children} + + ); +} + 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" )} > - {label} + + {label} + {!isCollapsed && badge && typeof badge !== "string" ? badge : null} + {!isCollapsed && badge && typeof badge === "string" ? ( + + {badge} + + ) : null} + {!isCollapsed && trailingContent} - {!isCollapsed && badge && typeof badge !== "string" ? badge : null} - {!isCollapsed && badge && typeof badge === "string" ? ( - - {badge} - - ) : null} {collapsedOverlay && ( 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 ( - - {open && ( -
- - {children} - -
- )} -
- ); - } - - return ( - - {open && ( - <> - {/* Blur backdrop covering the main content area (right of sidebar) */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel extending from sidebar's right edge, flush with the wrapper border */} - -
- {children} -
-
- - )} -
- ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index ea93ce4d0..d5e72174e 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -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(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 ( + setMobileSubmenu("theme")} + > + + {t("theme")} + + + ); + } + + return ( + + + + {t("theme")} + + + + {THEMES.map((themeOption) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + handleThemeChange(themeOption.value)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + + {t(themeOption.value)} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLanguageSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("language")} + > + + {t("language")} + + + ); + } + + return ( + + + + {t("language")} + + + + {LANGUAGES.map((language) => { + const isSelected = locale === language.code; + return ( + handleLanguageChange(language.code)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + {language.flag} + {language.name} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLearnMoreSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("learn_more")} + > + + {t("learn_more")} + + + ); + } + + return ( + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ ); + }; + + const mobileSubmenuDrawer = ( + { + if (!open) setMobileSubmenu(null); + }} + shouldScaleBackground={false} + > + + + + {mobileSubmenu === "theme" + ? t("theme") + : mobileSubmenu === "language" + ? t("language") + : t("learn_more")} + +
+ {mobileSubmenu === "theme" && + setTheme && + THEMES.map((themeOption, index) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "language" && + LANGUAGES.map((language, index) => { + const isSelected = locale === language.code; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "learn_more" && ( + <> + {LEARN_MORE_LINKS.map((link, index) => ( + + {index > 0 &&
} + setMobileSubmenu(null)} + > + {t(link.key)} + + + + ))} +

+ v{APP_VERSION} +

+ + )} +
+ + + ); + // Collapsed view - just show avatar with dropdown if (isCollapsed) { return ( -
+
+ {topContent} {showDownloadCta && ( @@ -247,89 +493,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && !isMobileOS && ( @@ -352,6 +520,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); @@ -429,89 +598,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && ( @@ -530,6 +621,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); } diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d1a1f85e5..e806b06d0 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -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"; diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx index b48b849c6..fdbf08d71 100644 --- a/surfsense_web/components/mcp/connect-agent-dialog.tsx +++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx @@ -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 )} > - - Connect your AI Agent +