feat: refactor announcements page and layout to support sidebar slide-out functionality

- Replaced the existing announcement card and empty state components with imports from the new announcements directory.
- Introduced state management for the announcements sidebar in the LayoutDataProvider.
- Updated navigation items to handle announcements sidebar toggling.
- Integrated AnnouncementsSidebar into the LayoutShell for both mobile and desktop views.
- Enhanced sidebar handling to improve user experience when navigating announcements.
This commit is contained in:
Eric Lammertsma 2026-03-03 13:09:29 -05:00
parent 9ea9538e95
commit e157ac9985
7 changed files with 283 additions and 152 deletions

View file

@ -0,0 +1,117 @@
"use client";
import {
Bell,
ExternalLink,
Info,
type LucideIcon,
Rocket,
Wrench,
Zap,
} from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import type { AnnouncementWithState } from "@/hooks/use-announcements";
import { formatRelativeDate } from "@/lib/format-date";
const categoryConfig: Record<
AnnouncementCategory,
{
label: string;
icon: LucideIcon;
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",
},
};
export function AnnouncementCard({ announcement }: { announcement: AnnouncementWithState }) {
const config = categoryConfig[announcement.category] ?? categoryConfig.info;
const Icon = config.icon;
return (
<Card className="group relative transition-all duration-200 hover:shadow-md">
<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>
)}
</div>
<CardDescription className="mt-1 text-xs">
{formatRelativeDate(announcement.date)}
</CardDescription>
</div>
</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}
>
{announcement.link.label}
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
</CardFooter>
)}
</Card>
);
}

View file

@ -0,0 +1,18 @@
"use client";
import { BellOff } from "lucide-react";
export function AnnouncementsEmptyState() {
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">
<BellOff className="h-7 w-7 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">No announcements</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
You're all caught up! New announcements will appear here.
</p>
</div>
);
}

View file

@ -114,6 +114,9 @@ export function LayoutDataProvider({
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false);
// Announcements sidebar state
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
@ -292,6 +295,12 @@ export function LayoutDataProvider({
// Navigation items
const navItems: NavItem[] = useMemo(
() => [
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Inbox",
url: "#inbox", // Special URL to indicate this is handled differently
@ -299,21 +308,22 @@ export function LayoutDataProvider({
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Announcements",
url: "/announcements",
url: "#announcements", // Special URL to indicate this is handled differently
icon: Megaphone,
isActive: pathname?.includes("/announcements"),
isActive: isAnnouncementsSidebarOpen,
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount]
[
searchSpaceId,
pathname,
isInboxSidebarOpen,
totalUnreadCount,
isAnnouncementsSidebarOpen,
announcementUnreadCount,
]
);
// Handlers
@ -411,6 +421,19 @@ export function LayoutDataProvider({
if (!prev) {
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
return;
}
// Handle announcements specially - toggle sidebar instead of navigating
if (item.url === "#announcements") {
setIsAnnouncementsSidebarOpen((prev) => {
if (!prev) {
setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
}
return !prev;
});
@ -418,7 +441,13 @@ export function LayoutDataProvider({
}
router.push(item.url);
},
[router]
[
router,
setIsAllPrivateChatsSidebarOpen,
setIsAllSharedChatsSidebarOpen,
setIsAnnouncementsSidebarOpen,
setIsInboxSidebarOpen,
]
);
const handleNewChat = useCallback(() => {
@ -515,12 +544,14 @@ export function LayoutDataProvider({
setIsAllSharedChatsSidebarOpen(true);
setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}, []);
const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true);
setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}, []);
// Delete handlers
@ -641,6 +672,10 @@ export function LayoutDataProvider({
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}}
announcementsPanel={{
open: isAnnouncementsSidebarOpen,
onOpenChange: setIsAnnouncementsSidebarOpen,
}}
allSharedChatsPanel={{
open: isAllSharedChatsSidebarOpen,
onOpenChange: setIsAllSharedChatsSidebarOpen,

View file

@ -13,6 +13,7 @@ import { IconRail } from "../icon-rail";
import {
AllPrivateChatsSidebar,
AllSharedChatsSidebar,
AnnouncementsSidebar,
InboxSidebar,
MobileSidebar,
MobileSidebarTrigger,
@ -82,6 +83,10 @@ interface LayoutShellProps {
className?: string;
// Inbox props
inbox?: InboxProps;
announcementsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
};
isLoadingChats?: boolean;
// All chats panel props
allSharedChatsPanel?: {
@ -130,6 +135,7 @@ export function LayoutShell({
children,
className,
inbox,
announcementsPanel,
isLoadingChats = false,
allSharedChatsPanel,
allPrivateChatsPanel,
@ -211,6 +217,15 @@ export function LayoutShell({
/>
)}
{/* Mobile Announcements Sidebar - only render when open to avoid scroll blocking */}
{announcementsPanel?.open && (
<AnnouncementsSidebar
open={announcementsPanel.open}
onOpenChange={announcementsPanel.onOpenChange}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
{/* Mobile All Shared Chats - slide-out panel */}
{allSharedChatsPanel && (
<AllSharedChatsSidebar
@ -325,6 +340,14 @@ export function LayoutShell({
/>
)}
{/* Announcements Sidebar - positioned absolutely on top of content */}
{announcementsPanel && (
<AnnouncementsSidebar
open={announcementsPanel.open}
onOpenChange={announcementsPanel.onOpenChange}
/>
)}
{/* All Shared Chats - slide-out panel */}
{allSharedChatsPanel && (
<AllSharedChatsSidebar

View file

@ -0,0 +1,75 @@
"use client";
import { ChevronLeft } from "lucide-react";
import { useEffect } from "react";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { Button } from "@/components/ui/button";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AnnouncementsSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCloseMobileSidebar?: () => void;
}
export function AnnouncementsSidebar({
open,
onOpenChange,
onCloseMobileSidebar,
}: AnnouncementsSidebarProps) {
const isMobile = !useMediaQuery("(min-width: 640px)");
const { announcements, markAllRead } = useAnnouncements();
useEffect(() => {
if (!open) return;
markAllRead();
}, [open, markAllRead]);
const body = (
<div className="h-full flex flex-col">
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
onOpenChange(false);
onCloseMobileSidebar?.();
}}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">Close</span>
</Button>
)}
<h2 className="text-lg font-semibold">Announcements</h2>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{announcements.length === 0 ? (
<AnnouncementsEmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</div>
)}
</div>
</div>
);
return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel="Announcements">
{body}
</SidebarSlideOutPanel>
);
}

View file

@ -1,5 +1,6 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
export { ChatListItem } from "./ChatListItem";
export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";