Updating
@@ -133,7 +149,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => handleVisibilityChange(option.value)}
disabled={isUpdating}
className={cn(
- "w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
+ "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
isSelected && "bg-accent/80"
@@ -141,13 +157,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
>
@@ -157,11 +173,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{option.label}
- {isSelected && (
-
- Current
-
- )}
{option.description}
diff --git a/surfsense_web/components/new-chat/model-config-sidebar.tsx b/surfsense_web/components/new-chat/model-config-sidebar.tsx
index 9d755f221..2e22612ad 100644
--- a/surfsense_web/components/new-chat/model-config-sidebar.tsx
+++ b/surfsense_web/components/new-chat/model-config-sidebar.tsx
@@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
@@ -38,6 +39,12 @@ export function ModelConfigSidebar({
mode,
}: ModelConfigSidebarProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ // Handle SSR - only render portal on client
+ useEffect(() => {
+ setMounted(true);
+ }, []);
// Mutations - use mutateAsync from the atom value
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
@@ -147,7 +154,9 @@ export function ModelConfigSidebar({
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
- return (
+ if (!mounted) return null;
+
+ const sidebarContent = (
{open && (
<>
@@ -157,7 +166,7 @@ export function ModelConfigSidebar({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
- className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
+ className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
@@ -172,7 +181,7 @@ export function ModelConfigSidebar({
stiffness: 300,
}}
className={cn(
- "fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
+ "fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
@@ -245,16 +254,16 @@ export function ModelConfigSidebar({
-
+
Configuration Name
-
+
{config.name}
{config.description && (
-
+
Description
-
+
{config.description}
)}
@@ -264,15 +273,15 @@ export function ModelConfigSidebar({
-
+
Provider
-
+
{config.provider}
-
+
Model
-
+
{config.model_name}
@@ -281,9 +290,9 @@ export function ModelConfigSidebar({
-
+
Citations
-
+
-
+
System Instructions
-
+
{config.system_instructions}
@@ -367,4 +376,6 @@ export function ModelConfigSidebar({
)}
);
+
+ return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index ead378e86..fba5e8cb1 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -170,59 +170,55 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
{isLoading ? (
<>
-
- Loading...
- Load...
+
+ Loading
>
) : currentConfig ? (
<>
{getProviderIcon(currentConfig.provider)}
- {currentConfig.name}
-
+
+ {currentConfig.name}
+
+
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
currentConfig.model_name.slice(0, 10)}
>
) : (
<>
-
+
Select Model
- Model
>
)}
-
+
{/* Switching overlay */}
{isSwitching && (
-
+
Switching model...
@@ -230,9 +226,9 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
)}
-
+
-
+
No models found
Try a different search term
@@ -264,8 +260,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer",
- "aria-selected:bg-accent/50",
+ "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
+ "hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
@@ -326,8 +322,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
- "mx-2 rounded-lg mb-1 cursor-pointer",
- "aria-selected:bg-accent/50",
+ "mx-2 rounded-lg mb-1 cursor-pointer transition-all",
+ "hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
diff --git a/surfsense_web/components/new-chat/source-detail-panel.tsx b/surfsense_web/components/new-chat/source-detail-panel.tsx
index df2809fdb..158a8ea63 100644
--- a/surfsense_web/components/new-chat/source-detail-panel.tsx
+++ b/surfsense_web/components/new-chat/source-detail-panel.tsx
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
- ChevronUp,
ExternalLink,
FileText,
Hash,
@@ -387,7 +386,7 @@ export function SourceDetailPanel({
-
Loading document...
+
Loading document
)}
diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx
index acecc06af..020fea506 100644
--- a/surfsense_web/components/notifications/NotificationButton.tsx
+++ b/surfsense_web/components/notifications/NotificationButton.tsx
@@ -3,27 +3,64 @@
import { useAtomValue } from "jotai";
import { Bell } from "lucide-react";
import { useParams } from "next/navigation";
-import { useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { useNotifications } from "@/hooks/use-notifications";
+import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
import { NotificationPopup } from "./NotificationPopup";
+const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
+
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
+ // Filter state - null means show all, otherwise filter by type
+ const [activeFilter, setActiveFilter] = useState
(null);
+
+ // Load filter from localStorage on mount
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (
+ parsed === null ||
+ ["new_mention", "connector_indexing", "document_processing"].includes(parsed)
+ ) {
+ setActiveFilter(parsed);
+ }
+ }
+ } catch {
+ // Ignore localStorage errors
+ }
+ }, []);
+
+ // Handle filter toggle - clicking same pill again shows all
+ const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
+ setActiveFilter((current) => {
+ const newFilter = current === filter ? null : filter;
+ try {
+ localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
+ } catch {
+ // Ignore localStorage errors
+ }
+ return newFilter;
+ });
+ }, []);
+
const userId = user?.id ? String(user.id) : null;
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
userId,
- searchSpaceId
+ searchSpaceId,
+ activeFilter
);
return (
@@ -31,7 +68,7 @@ export function NotificationButton() {
-
+
{unreadCount > 0 && (
setOpen(false)}
+ activeFilter={activeFilter}
+ onFilterChange={handleFilterChange}
/>
diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx
index 50deadf03..fbb756a00 100644
--- a/surfsense_web/components/notifications/NotificationPopup.tsx
+++ b/surfsense_web/components/notifications/NotificationPopup.tsx
@@ -1,15 +1,53 @@
"use client";
import { formatDistanceToNow } from "date-fns";
-import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
+import {
+ AlertCircle,
+ AtSign,
+ Bell,
+ Cable,
+ CheckCheck,
+ CheckCircle2,
+ FileText,
+ Loader2,
+} from "lucide-react";
import { useRouter } from "next/navigation";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
-import type { Notification } from "@/hooks/use-notifications";
+import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
+/**
+ * Filter configuration for notification types
+ */
+const NOTIFICATION_FILTERS = {
+ new_mention: { label: "Mentions", icon: AtSign },
+ connector_indexing: { label: "Connectors", icon: Cable },
+ document_processing: { label: "Documents", icon: FileText },
+} as const;
+
+/**
+ * Get initials from name or email for avatar fallback
+ */
+function getInitials(name: string | null | undefined, email: string | null | undefined): string {
+ if (name) {
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+ }
+ if (email) {
+ const localPart = email.split("@")[0];
+ return localPart.slice(0, 2).toUpperCase();
+ }
+ return "U";
+}
+
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
@@ -17,6 +55,8 @@ interface NotificationPopupProps {
markAsRead: (id: number) => Promise;
markAllAsRead: () => Promise;
onClose?: () => void;
+ activeFilter: NotificationTypeEnum | null;
+ onFilterChange: (filter: NotificationTypeEnum | null) => void;
}
export function NotificationPopup({
@@ -26,6 +66,8 @@ export function NotificationPopup({
markAsRead,
markAllAsRead,
onClose,
+ activeFilter,
+ onFilterChange,
}: NotificationPopupProps) {
const router = useRouter();
@@ -66,6 +108,28 @@ export function NotificationPopup({
};
const getStatusIcon = (notification: Notification) => {
+ // For mentions, show the author's avatar with initials fallback
+ if (notification.type === "new_mention") {
+ const metadata = notification.metadata as {
+ author_name?: string;
+ author_avatar_url?: string | null;
+ author_email?: string;
+ };
+ const authorName = metadata?.author_name;
+ const avatarUrl = metadata?.author_avatar_url;
+ const authorEmail = metadata?.author_email;
+
+ return (
+
+ {avatarUrl && }
+
+ {getInitials(authorName, authorEmail)}
+
+
+ );
+ }
+
+ // For other notification types, show status icons
const status = notification.metadata?.status as string | undefined;
switch (status) {
@@ -83,7 +147,7 @@ export function NotificationPopup({
return (
{/* Header */}
-
+
Notifications
@@ -95,6 +159,35 @@ export function NotificationPopup({
)}
+ {/* Filter Pills */}
+
+ {(
+ Object.entries(NOTIFICATION_FILTERS) as [
+ NotificationTypeEnum,
+ (typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
+ ][]
+ ).map(([key, { label, icon: Icon }]) => {
+ const isActive = activeFilter === key;
+ return (
+ onFilterChange(key)}
+ className={cn(
+ "inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
+ "border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
+ isActive
+ ? "bg-primary text-primary-foreground border-primary"
+ : "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
+ )}
+ >
+
+ {label}
+
+ );
+ })}
+
+
{/* Notifications List */}
{loading ? (
diff --git a/surfsense_web/components/ui/dropdown-menu.tsx b/surfsense_web/components/ui/dropdown-menu.tsx
index 810827ea4..a9df1a5b2 100644
--- a/surfsense_web/components/ui/dropdown-menu.tsx
+++ b/surfsense_web/components/ui/dropdown-menu.tsx
@@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
- "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
-
+
);
}
diff --git a/surfsense_web/components/ui/select.tsx b/surfsense_web/components/ui/select.tsx
index 45e7ce218..c214f1c59 100644
--- a/surfsense_web/components/ui/select.tsx
+++ b/surfsense_web/components/ui/select.tsx
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
-import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
@@ -94,16 +94,11 @@ function SelectItem({
-
-
-
-
-
{children}
);
diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/notification.types.ts
index afd4f1232..b2b39d26e 100644
--- a/surfsense_web/contracts/types/notification.types.ts
+++ b/surfsense_web/contracts/types/notification.types.ts
@@ -83,6 +83,8 @@ export const newMentionMetadata = z.object({
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
+ author_avatar_url: z.string().nullable().optional(),
+ author_email: z.string().optional(),
content_preview: z.string(),
});
diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts
index fbdf421de..eca00a935 100644
--- a/surfsense_web/hooks/use-notifications.ts
+++ b/surfsense_web/hooks/use-notifications.ts
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import type { Notification } from "@/contracts/types/notification.types";
+import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
-export type { Notification } from "@/contracts/types/notification.types";
+export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
/**
* Hook for managing notifications with Electric SQL real-time sync
@@ -22,16 +22,23 @@ export type { Notification } from "@/contracts/types/notification.types";
*
* @param userId - The user ID to fetch notifications for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
+ * @param typeFilter - Optional notification type to filter by (null shows all types)
*/
-export function useNotifications(userId: string | null, searchSpaceId: number | null) {
+export function useNotifications(
+ userId: string | null,
+ searchSpaceId: number | null,
+ typeFilter: NotificationTypeEnum | null = null
+) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [notifications, setNotifications] = useState([]);
+ const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const syncHandleRef = useRef(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef(null);
@@ -108,7 +115,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
};
}, [userId, electricClient]);
- // EFFECT 2: Search-space-level query - updates when searchSpaceId changes
+ // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
// This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => {
if (!userId || !electricClient) {
@@ -125,16 +132,24 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
}
try {
- console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId);
+ console.log(
+ "[useNotifications] Updating query for searchSpace:",
+ searchSpaceId,
+ "typeFilter:",
+ typeFilter
+ );
+
+ // Build query with optional type filter
+ const baseQuery = `SELECT * FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)`;
+ const typeClause = typeFilter ? ` AND type = $3` : "";
+ const orderClause = ` ORDER BY created_at DESC`;
+ const fullQuery = baseQuery + typeClause + orderClause;
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch notifications for current search space immediately
- const result = await electricClient.db.query(
- `SELECT * FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)
- ORDER BY created_at DESC`,
- [userId, searchSpaceId]
- );
+ const result = await electricClient.db.query(fullQuery, params);
if (mounted) {
setNotifications(result.rows || []);
@@ -145,13 +160,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(
- `SELECT * FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)
- ORDER BY created_at DESC`,
- [userId, searchSpaceId]
- );
+ const liveQuery = await db.live.query(fullQuery, params);
if (!mounted) {
liveQuery.unsubscribe?.();
@@ -192,6 +201,86 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
liveQueryRef.current = null;
}
};
+ }, [userId, searchSpaceId, typeFilter, electricClient]);
+
+ // EFFECT 3: Total unread count - independent of type filter
+ // This ensures the badge count stays consistent regardless of active filter
+ useEffect(() => {
+ if (!userId || !electricClient) {
+ return;
+ }
+
+ let mounted = true;
+
+ async function updateUnreadCount() {
+ // Clean up previous live query
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+
+ try {
+ const countQuery = `SELECT COUNT(*) as count FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND read = false`;
+
+ // Fetch initial count
+ const result = await electricClient.db.query<{ count: number }>(countQuery, [
+ userId,
+ searchSpaceId,
+ ]);
+
+ if (mounted && result.rows?.[0]) {
+ setTotalUnreadCount(Number(result.rows[0].count) || 0);
+ }
+
+ // Set up live query for real-time updates
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = electricClient.db as any;
+
+ if (db.live?.query && typeof db.live.query === "function") {
+ const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ // Set initial results from live query
+ if (liveQuery.initialResults?.rows?.[0]) {
+ setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
+ } else if (liveQuery.rows?.[0]) {
+ setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
+ }
+
+ // Subscribe to changes
+ if (typeof liveQuery.subscribe === "function") {
+ liveQuery.subscribe((result: { rows: { count: number }[] }) => {
+ if (mounted && result.rows?.[0]) {
+ setTotalUnreadCount(Number(result.rows[0].count) || 0);
+ }
+ });
+ }
+
+ if (typeof liveQuery.unsubscribe === "function") {
+ unreadCountLiveQueryRef.current = liveQuery;
+ }
+ }
+ } catch (err) {
+ console.error("[useNotifications] Failed to update unread count:", err);
+ }
+ }
+
+ updateUnreadCount();
+
+ return () => {
+ mounted = false;
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+ };
}, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API
@@ -234,12 +323,9 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
}
}, []);
- // Get unread count
- const unreadCount = notifications.filter((n) => !n.read).length;
-
return {
notifications,
- unreadCount,
+ unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
loading,
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 0c6fe63a8..cda522b61 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -687,6 +687,11 @@
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
"user_settings": "User settings",
+ "language": "Language",
+ "theme": "Theme",
+ "light": "Light",
+ "dark": "Dark",
+ "system": "System",
"logout": "Logout"
},
"errors": {
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index b48e3e9c7..7f2f49cfc 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -672,6 +672,11 @@
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",
"user_settings": "用户设置",
+ "language": "语言",
+ "theme": "主题",
+ "light": "浅色",
+ "dark": "深色",
+ "system": "系统",
"logout": "退出登录"
},
"errors": {
diff --git a/surfsense_web/tailwind.config.js b/surfsense_web/tailwind.config.js
index 8bd78ed2b..72123aa38 100644
--- a/surfsense_web/tailwind.config.js
+++ b/surfsense_web/tailwind.config.js
@@ -52,6 +52,9 @@ module.exports = {
},
},
borderRadius: {
+ "3xl": "calc(var(--radius) + 12px)",
+ "2xl": "calc(var(--radius) + 8px)",
+ xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",