mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
feat(announcements): enhance AnnouncementCard and add spotlight feature
- Added image support to the AnnouncementCard component for improved visual presentation of announcements. - Introduced a spotlight feature in the announcement types to allow critical announcements to be displayed in a blocking dialog until acknowledged. - Updated AnnouncementToastProvider to skip spotlight announcements to prevent duplicate notifications. - Included a new AI automation announcement with an image in the announcements data for demonstration purposes.
This commit is contained in:
parent
0ae30839aa
commit
ec0342faa2
7 changed files with 154 additions and 2 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -49,7 +50,18 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group relative transition-all duration-200 hover:shadow-md">
|
<Card className="group relative overflow-hidden transition-all duration-200 hover:shadow-md">
|
||||||
|
{announcement.image && (
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden border-b bg-muted">
|
||||||
|
<Image
|
||||||
|
src={announcement.image.src}
|
||||||
|
alt={announcement.image.alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 95vw, 600px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
|
|
||||||
101
surfsense_web/components/announcements/AnnouncementSpotlight.tsx
Normal file
101
surfsense_web/components/announcements/AnnouncementSpotlight.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactively shows important "spotlight" announcements in a blocking dialog.
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - On load, the first active, audience-matched, unread spotlight announcement
|
||||||
|
* is shown automatically.
|
||||||
|
* - The user must explicitly acknowledge it ("Got it" or the CTA link), which
|
||||||
|
* marks it as read so it never shows again.
|
||||||
|
* - Closing via the X / Escape / outside-click only hides it for the current
|
||||||
|
* session; it reappears on the next load until the user marks it as seen.
|
||||||
|
*/
|
||||||
|
export function AnnouncementSpotlight() {
|
||||||
|
const { announcements, markRead } = useAnnouncements();
|
||||||
|
const [sessionDismissed, setSessionDismissed] = useState<Set<string>>(() => new Set());
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
// Short delay so the spotlight doesn't flash during initial hydration/layout.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setReady(true), 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const current = useMemo(
|
||||||
|
() =>
|
||||||
|
announcements.find(
|
||||||
|
(a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id)
|
||||||
|
) ?? null,
|
||||||
|
[announcements, sessionDismissed]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const handleAcknowledge = () => {
|
||||||
|
markRead(current.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next) {
|
||||||
|
setSessionDismissed((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.add(current.id);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={ready} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
|
||||||
|
{current.image && (
|
||||||
|
<div className="relative aspect-video w-full border-b bg-muted">
|
||||||
|
<Image
|
||||||
|
src={current.image.src}
|
||||||
|
alt={current.image.alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 95vw, 448px"
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3 p-6">
|
||||||
|
<DialogTitle className="text-xl">{current.title}</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{current.description}
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogFooter className="mt-2">
|
||||||
|
{current.link && (
|
||||||
|
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
|
||||||
|
<Link
|
||||||
|
href={current.link.url}
|
||||||
|
target={current.link.url.startsWith("http") ? "_blank" : undefined}
|
||||||
|
>
|
||||||
|
{current.link.label}
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleAcknowledge}>Got it</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -70,8 +70,10 @@ export function AnnouncementToastProvider() {
|
||||||
const outerTimer = setTimeout(() => {
|
const outerTimer = setTimeout(() => {
|
||||||
const authed = isAuthenticated();
|
const authed = isAuthenticated();
|
||||||
const active = getActiveAnnouncements(announcements, authed);
|
const active = getActiveAnnouncements(announcements, authed);
|
||||||
|
// Spotlight announcements are handled by the blocking spotlight dialog,
|
||||||
|
// so skip them here to avoid double-notifying the user.
|
||||||
const importantUntoasted = active.filter(
|
const importantUntoasted = active.filter(
|
||||||
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
(a) => a.isImportant && !a.spotlight && !isAnnouncementToasted(a.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < importantUntoasted.length; i++) {
|
for (let i = 0; i < importantUntoasted.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms
|
||||||
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
||||||
|
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
||||||
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -909,6 +910,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnnouncementsDialog />
|
<AnnouncementsDialog />
|
||||||
|
<AnnouncementSpotlight />
|
||||||
|
|
||||||
{/* Agent action log + revert dialog */}
|
{/* Agent action log + revert dialog */}
|
||||||
<ActionLogDialog />
|
<ActionLogDialog />
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,20 @@ export interface Announcement {
|
||||||
audience: AnnouncementAudience;
|
audience: AnnouncementAudience;
|
||||||
/** If true, the user will see a toast notification for this announcement */
|
/** If true, the user will see a toast notification for this announcement */
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
|
/**
|
||||||
|
* If true, this announcement is shown in a blocking spotlight dialog that the
|
||||||
|
* user must explicitly acknowledge ("Got it"). Until acknowledged it keeps
|
||||||
|
* reappearing; once acknowledged it never shows again. Spotlight announcements
|
||||||
|
* are skipped by the lightweight toast provider to avoid double notifications.
|
||||||
|
*/
|
||||||
|
spotlight?: boolean;
|
||||||
|
/** Optional head/banner image shown at the top of the announcement */
|
||||||
|
image?: {
|
||||||
|
/** Image source (public path or absolute URL) */
|
||||||
|
src: string;
|
||||||
|
/** Accessible alt text */
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
/** Optional CTA link */
|
/** Optional CTA link */
|
||||||
link?: {
|
link?: {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,27 @@ import type { Announcement } from "@/contracts/types/announcement.types";
|
||||||
* This file can be replaced with an API call in the future.
|
* This file can be replaced with an API call in the future.
|
||||||
*/
|
*/
|
||||||
export const announcements: Announcement[] = [
|
export const announcements: Announcement[] = [
|
||||||
|
{
|
||||||
|
id: "2026-05-31-ai-automations",
|
||||||
|
title: "Introducing AI Automations",
|
||||||
|
description:
|
||||||
|
"Turn prompts into hands-off AI agent workflows. Describe an automation in plain English and SurfSense builds it, run it on a schedule, or trigger it the moment a document lands in a folder. Automations work across Notion, Slack, Google Drive, Gmail, GitHub, Linear, Jira and more.",
|
||||||
|
category: "feature",
|
||||||
|
date: "2026-05-31T00:00:00Z",
|
||||||
|
startTime: "2026-05-31T00:00:00Z",
|
||||||
|
endTime: "2026-07-15T00:00:00Z",
|
||||||
|
audience: "users",
|
||||||
|
isImportant: true,
|
||||||
|
spotlight: true,
|
||||||
|
image: {
|
||||||
|
src: "/announcements/automations.png",
|
||||||
|
alt: "Connector tiles flowing into a central AI core that triggers scheduled and event-driven automations.",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
label: "See what's new",
|
||||||
|
url: "/changelog",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "announcement-1",
|
id: "announcement-1",
|
||||||
title: "Introducing What's New",
|
title: "Introducing What's New",
|
||||||
|
|
|
||||||
BIN
surfsense_web/public/announcements/automations.png
Normal file
BIN
surfsense_web/public/announcements/automations.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 MiB |
Loading…
Add table
Add a link
Reference in a new issue