mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 19:06:24 +02:00
feat: added announcements
This commit is contained in:
parent
0e96e4492b
commit
e9979dfa7d
11 changed files with 833 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue