mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 05:12:38 +02:00
feat: added announcements
This commit is contained in:
parent
0e96e4492b
commit
e9979dfa7d
11 changed files with 833 additions and 3 deletions
350
surfsense_web/app/(home)/announcements/page.tsx
Normal file
350
surfsense_web/app/(home)/announcements/page.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
CheckCheck,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Info,
|
||||
type Megaphone,
|
||||
Rocket,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const categoryConfig: Record<
|
||||
AnnouncementCategory,
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Megaphone;
|
||||
color: string;
|
||||
badgeVariant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
feature: {
|
||||
label: "Feature",
|
||||
icon: Rocket,
|
||||
color: "text-emerald-500",
|
||||
badgeVariant: "default",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
icon: Zap,
|
||||
color: "text-blue-500",
|
||||
badgeVariant: "secondary",
|
||||
},
|
||||
maintenance: {
|
||||
label: "Maintenance",
|
||||
icon: Wrench,
|
||||
color: "text-amber-500",
|
||||
badgeVariant: "outline",
|
||||
},
|
||||
info: {
|
||||
label: "Info",
|
||||
icon: Info,
|
||||
color: "text-muted-foreground",
|
||||
badgeVariant: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Announcement card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AnnouncementCard({
|
||||
announcement,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
}: {
|
||||
announcement: AnnouncementWithState;
|
||||
onMarkRead: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
}) {
|
||||
const config = categoryConfig[announcement.category];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group relative transition-all duration-200 hover:shadow-md ${
|
||||
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted ${config.color}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base leading-tight">{announcement.title}</CardTitle>
|
||||
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{announcement.isImportant && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 gap-0.5">
|
||||
<Bell className="h-2.5 w-2.5" />
|
||||
Important
|
||||
</Badge>
|
||||
)}
|
||||
{!announcement.isRead && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{formatRelativeDate(announcement.date)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!announcement.isRead && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onMarkRead(announcement.id)}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDismiss(announcement.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dismiss</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{announcement.description}</p>
|
||||
</CardContent>
|
||||
|
||||
{announcement.link && (
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<Button variant="outline" size="sm" asChild className="gap-1.5">
|
||||
<Link
|
||||
href={announcement.link.url}
|
||||
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
|
||||
onClick={() => onMarkRead(announcement.id)}
|
||||
>
|
||||
{announcement.link.label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
{hasFilters ? (
|
||||
<Filter className="h-7 w-7 text-muted-foreground" />
|
||||
) : (
|
||||
<BellOff className="h-7 w-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{hasFilters ? "No matching announcements" : "No announcements"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
{hasFilters
|
||||
? "Try adjusting your filters to see more announcements."
|
||||
: "You're all caught up! New announcements will appear here."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AnnouncementsPage() {
|
||||
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
|
||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
||||
|
||||
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
|
||||
includeDismissed: false,
|
||||
});
|
||||
|
||||
// Apply local filters
|
||||
const filteredAnnouncements = announcements.filter((a) => {
|
||||
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
|
||||
if (showOnlyUnread && a.isRead) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
|
||||
|
||||
const toggleCategory = (cat: AnnouncementCategory) => {
|
||||
setActiveCategories((prev) =>
|
||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="min-h-screen relative pt-20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border/50">
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||
Announcements
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Category filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filter
|
||||
{activeCategories.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||
{activeCategories.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuLabel>Categories</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
|
||||
const cfg = categoryConfig[cat];
|
||||
const CatIcon = cfg.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={cat}
|
||||
checked={activeCategories.includes(cat)}
|
||||
onCheckedChange={() => toggleCategory(cat)}
|
||||
>
|
||||
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
|
||||
{cfg.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showOnlyUnread}
|
||||
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
|
||||
>
|
||||
<Bell className="mr-2 h-3.5 w-3.5" />
|
||||
Unread only
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setActiveCategories([]);
|
||||
setShowOnlyUnread(false);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mark all read */}
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all as read
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{/* Announcement list */}
|
||||
{filteredAnnouncements.length === 0 ? (
|
||||
<EmptyState hasFilters={hasActiveFilters} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredAnnouncements.map((announcement) => (
|
||||
<AnnouncementCard
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
onMarkRead={markRead}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import "./globals.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
|
||||
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
|
|
@ -124,6 +125,7 @@ export default function RootLayout({
|
|||
</ElectricProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Announcement } from "@/contracts/types/announcement.types";
|
||||
import { announcements } from "@/lib/announcements/announcements-data";
|
||||
import {
|
||||
isAnnouncementToasted,
|
||||
markAnnouncementRead,
|
||||
markAnnouncementToasted,
|
||||
} from "@/lib/announcements/announcements-storage";
|
||||
|
||||
/** Map announcement category to the Sonner toast method */
|
||||
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
|
||||
update: "info",
|
||||
feature: "success",
|
||||
maintenance: "warning",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
/** Show a single announcement as a toast */
|
||||
function showAnnouncementToast(announcement: Announcement) {
|
||||
const variant = categoryToVariant[announcement.category] ?? "info";
|
||||
|
||||
const options = {
|
||||
description: truncateText(announcement.description, 120),
|
||||
duration: 12000,
|
||||
icon: <Megaphone className="h-4 w-4" />,
|
||||
action: announcement.link
|
||||
? {
|
||||
label: announcement.link.label,
|
||||
onClick: () => {
|
||||
if (announcement.link?.url.startsWith("http")) {
|
||||
window.open(announcement.link.url, "_blank");
|
||||
} else if (announcement.link?.url) {
|
||||
window.location.href = announcement.link.url;
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
onDismiss: () => {
|
||||
markAnnouncementRead(announcement.id);
|
||||
},
|
||||
};
|
||||
|
||||
toast[variant](announcement.title, options);
|
||||
markAnnouncementToasted(announcement.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global provider that shows important announcements as toast notifications.
|
||||
*
|
||||
* Place this component once at the root layout level (alongside <Toaster />).
|
||||
* On mount, it checks for unread important announcements that haven't been
|
||||
* shown as toasts yet, and displays them with a short stagger delay.
|
||||
*/
|
||||
export function AnnouncementToastProvider() {
|
||||
const hasChecked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once per page load
|
||||
if (hasChecked.current) return;
|
||||
hasChecked.current = true;
|
||||
|
||||
// Small delay to let the page settle before showing toasts
|
||||
const timer = setTimeout(() => {
|
||||
const importantUntoasted = announcements.filter(
|
||||
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
||||
);
|
||||
|
||||
// Show each important announcement as a toast with stagger
|
||||
for (let i = 0; i < importantUntoasted.length; i++) {
|
||||
const announcement = importantUntoasted[i];
|
||||
setTimeout(() => showAnnouncementToast(announcement), i * 800);
|
||||
}
|
||||
}, 1500); // Initial delay for page to settle
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// This component renders nothing — it only triggers side effects
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Truncate text to a maximum length, adding ellipsis if needed */
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`;
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ export function FooterNew() {
|
|||
title: "Contact Us",
|
||||
href: "/contact",
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
href: "/announcements",
|
||||
},
|
||||
];
|
||||
|
||||
const socials = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IconBrandGithub,
|
||||
IconBrandReddit,
|
||||
IconMenu2,
|
||||
IconSpeakerphone,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
|
@ -12,6 +13,7 @@ import { useEffect, useState } from "react";
|
|||
import { SignInButton } from "@/components/auth/sign-in-button";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useGithubStars } from "@/hooks/use-github-stars";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -47,7 +49,11 @@ export const Navbar = () => {
|
|||
|
||||
const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
|
||||
const { unreadCount } = useAnnouncements();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => {
|
||||
|
|
@ -118,6 +124,17 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="relative hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
{mounted && unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
<SignInButton variant="desktop" />
|
||||
</div>
|
||||
|
|
@ -127,7 +144,11 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
|
||||
const MobileNav = ({ navItems, isScrolled }: any) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
|
||||
const { unreadCount } = useAnnouncements();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -212,6 +233,17 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
|||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
{mounted && unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
<SignInButton variant="mobile" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
PencilIcon,
|
||||
SquareLibrary,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -23,6 +31,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
|
|
@ -65,6 +74,9 @@ export function LayoutDataProvider({
|
|||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
|
|
@ -293,8 +305,15 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
url: "/announcements",
|
||||
icon: Megaphone,
|
||||
isActive: pathname?.includes("/announcements"),
|
||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import type React from "react";
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import enMessages from "../messages/en.json";
|
||||
import esMessages from "../messages/es.json";
|
||||
import ptMessages from "../messages/pt.json";
|
||||
import hiMessages from "../messages/hi.json";
|
||||
import ptMessages from "../messages/pt.json";
|
||||
import zhMessages from "../messages/zh.json";
|
||||
|
||||
type Locale = "en" | "es" | "pt" | "hi" | "zh";
|
||||
|
|
|
|||
42
surfsense_web/contracts/types/announcement.types.ts
Normal file
42
surfsense_web/contracts/types/announcement.types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Announcement system types
|
||||
*
|
||||
* Frontend-only announcement system that supports:
|
||||
* - Multiple announcement categories (update, feature, maintenance, info)
|
||||
* - Important flag for toast notifications
|
||||
* - Read/dismissed state tracking via localStorage
|
||||
*/
|
||||
|
||||
/** Announcement category */
|
||||
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
|
||||
|
||||
/** Single announcement entry */
|
||||
export interface Announcement {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Short title */
|
||||
title: string;
|
||||
/** Full description (supports basic text) */
|
||||
description: string;
|
||||
/** Category for visual styling and filtering */
|
||||
category: AnnouncementCategory;
|
||||
/** ISO date string of when the announcement was published */
|
||||
date: string;
|
||||
/** If true, the user will see a toast notification for this announcement */
|
||||
isImportant: boolean;
|
||||
/** Optional CTA link */
|
||||
link?: {
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** State stored in localStorage for tracking user interactions */
|
||||
export interface AnnouncementUserState {
|
||||
/** IDs of announcements the user has read (clicked/viewed) */
|
||||
readIds: string[];
|
||||
/** IDs of important announcements already shown as toasts */
|
||||
toastedIds: string[];
|
||||
/** IDs of announcements the user has explicitly dismissed */
|
||||
dismissedIds: string[];
|
||||
}
|
||||
119
surfsense_web/hooks/use-announcements.ts
Normal file
119
surfsense_web/hooks/use-announcements.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||
import { announcements } from "@/lib/announcements/announcements-data";
|
||||
import {
|
||||
dismissAnnouncement,
|
||||
getAnnouncementState,
|
||||
isAnnouncementDismissed,
|
||||
isAnnouncementRead,
|
||||
markAllAnnouncementsRead,
|
||||
markAnnouncementRead,
|
||||
} from "@/lib/announcements/announcements-storage";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External-store plumbing so React re-renders when localStorage changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let stateVersion = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return stateVersion;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Bump the version so useSyncExternalStore triggers a re-render */
|
||||
function notify() {
|
||||
stateVersion++;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enriched announcement with read/dismissed state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AnnouncementWithState extends Announcement {
|
||||
isRead: boolean;
|
||||
isDismissed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseAnnouncementsOptions {
|
||||
/** Filter by category */
|
||||
category?: AnnouncementCategory;
|
||||
/** If true, include dismissed announcements (default: false) */
|
||||
includeDismissed?: boolean;
|
||||
}
|
||||
|
||||
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
|
||||
const { category, includeDismissed = false } = options;
|
||||
|
||||
// Subscribe to state changes (re-renders when localStorage state is bumped)
|
||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const enriched: AnnouncementWithState[] = useMemo(() => {
|
||||
let items = announcements.map((a) => ({
|
||||
...a,
|
||||
isRead: isAnnouncementRead(a.id),
|
||||
isDismissed: isAnnouncementDismissed(a.id),
|
||||
}));
|
||||
|
||||
if (category) {
|
||||
items = items.filter((a) => a.category === category);
|
||||
}
|
||||
|
||||
if (!includeDismissed) {
|
||||
items = items.filter((a) => !a.isDismissed);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return items;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [category, includeDismissed, stateVersion]);
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
|
||||
[enriched]
|
||||
);
|
||||
|
||||
const handleMarkRead = useCallback((id: string) => {
|
||||
markAnnouncementRead(id);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
const state = getAnnouncementState();
|
||||
const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
|
||||
markAllAnnouncementsRead(unreadIds);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
dismissAnnouncement(id);
|
||||
markAnnouncementRead(id);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
announcements: enriched,
|
||||
unreadCount,
|
||||
markRead: handleMarkRead,
|
||||
markAllRead: handleMarkAllRead,
|
||||
dismiss: handleDismiss,
|
||||
};
|
||||
}
|
||||
65
surfsense_web/lib/announcements/announcements-data.ts
Normal file
65
surfsense_web/lib/announcements/announcements-data.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Announcement } from "@/contracts/types/announcement.types";
|
||||
|
||||
/**
|
||||
* Static announcements data.
|
||||
*
|
||||
* To add a new announcement, append an entry to this array.
|
||||
* Set `isImportant: true` to trigger a toast notification for the user.
|
||||
*
|
||||
* This file can be replaced with an API call in the future.
|
||||
*/
|
||||
export const announcements: Announcement[] = [
|
||||
{
|
||||
id: "2026-02-12-announcement-syste",
|
||||
title: "Introducing Announcements",
|
||||
description:
|
||||
"Stay up to date with the latest SurfSense news! Important announcements will appear as toast notifications so you never miss critical updates. Visit the Announcements page from the sidebar to browse all past announcements.",
|
||||
category: "feature",
|
||||
date: "2026-02-12T00:00:00Z",
|
||||
isImportant: true,
|
||||
link: {
|
||||
label: "Learn more",
|
||||
url: "/changelog",
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: "2026-02-10-podcast-improvements",
|
||||
// title: "Podcast Generation Improvements",
|
||||
// description:
|
||||
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
|
||||
// category: "update",
|
||||
// date: "2026-02-10T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// },
|
||||
// {
|
||||
// id: "2026-02-08-scheduled-maintenance",
|
||||
// title: "Scheduled Maintenance — Feb 15",
|
||||
// description:
|
||||
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
|
||||
// category: "maintenance",
|
||||
// date: "2026-02-08T00:00:00Z",
|
||||
// isImportant: true,
|
||||
// },
|
||||
// {
|
||||
// id: "2026-02-05-new-connectors",
|
||||
// title: "New Connectors Available",
|
||||
// description:
|
||||
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
|
||||
// category: "feature",
|
||||
// date: "2026-02-05T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// link: {
|
||||
// label: "View connectors",
|
||||
// url: "#connectors",
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// id: "2026-01-28-team-collaboration",
|
||||
// title: "Enhanced Team Collaboration",
|
||||
// description:
|
||||
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
|
||||
// category: "feature",
|
||||
// date: "2026-01-28T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// },
|
||||
];
|
||||
107
surfsense_web/lib/announcements/announcements-storage.ts
Normal file
107
surfsense_web/lib/announcements/announcements-storage.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { AnnouncementUserState } from "@/contracts/types/announcement.types";
|
||||
|
||||
const STORAGE_KEY = "surfsense_announcements_state";
|
||||
|
||||
const defaultState: AnnouncementUserState = {
|
||||
readIds: [],
|
||||
toastedIds: [],
|
||||
dismissedIds: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current announcement user state from localStorage
|
||||
*/
|
||||
export function getAnnouncementState(): AnnouncementUserState {
|
||||
if (typeof window === "undefined") return defaultState;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultState;
|
||||
const parsed = JSON.parse(raw) as Partial<AnnouncementUserState>;
|
||||
return {
|
||||
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
|
||||
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
|
||||
dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [],
|
||||
};
|
||||
} catch {
|
||||
return defaultState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save announcement user state to localStorage
|
||||
*/
|
||||
function saveAnnouncementState(state: AnnouncementUserState): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Silently fail if localStorage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an announcement as read
|
||||
*/
|
||||
export function markAnnouncementRead(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.readIds.includes(id)) {
|
||||
state.readIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all announcements as read
|
||||
*/
|
||||
export function markAllAnnouncementsRead(ids: string[]): void {
|
||||
const state = getAnnouncementState();
|
||||
const newIds = ids.filter((id) => !state.readIds.includes(id));
|
||||
if (newIds.length > 0) {
|
||||
state.readIds.push(...newIds);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an announcement (hide it from the list)
|
||||
*/
|
||||
export function dismissAnnouncement(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.dismissedIds.includes(id)) {
|
||||
state.dismissedIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an important announcement as already toasted (shown as toast)
|
||||
*/
|
||||
export function markAnnouncementToasted(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.toastedIds.includes(id)) {
|
||||
state.toastedIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been read
|
||||
*/
|
||||
export function isAnnouncementRead(id: string): boolean {
|
||||
return getAnnouncementState().readIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been toasted
|
||||
*/
|
||||
export function isAnnouncementToasted(id: string): boolean {
|
||||
return getAnnouncementState().toastedIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been dismissed
|
||||
*/
|
||||
export function isAnnouncementDismissed(id: string): boolean {
|
||||
return getAnnouncementState().dismissedIds.includes(id);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue