Meldingen als kaart: kop met titel en knoppen (alles gelezen, instellingen), tabbladen met tellers die de lijst filteren, en per melding een portret, wie-deed-wat-waar, tijdstip en hoe lang geleden, een ongelezen-stip, optioneel een citaat, een bestand met downloadknop of accepteren/afwijzen. Leeg: belletje met tekst. Componeert tabs en button; zusje van notification-popover (dat is de bel met paneel, dit de volle lijst).
Je meldingen
Amélie Peeters reageerde in Dashboard 2.0
2 uur geledenMooie aanpak — ik denk dat dit de beste oplossing is voor het synchronisatieprobleem.Sanne Koster volgt je nu
2 uur geledenAmir Haddad nodigde je uit voor Blogontwerp
3 uur geledenMathilde Vos deelde een bestand in Dashboard 2.0
4 uur geledenPrototype-opname 01.mp4MP4 • 14 MBJasper de Wit noemde je in Project Alpha
1 dag geledenHé @jij, kun je de nieuwste ontwerpen bekijken als je even hebt?Sofia Almeida vond je reactie leuk in Notulen teamoverleg
1 dag geleden
Drie tabbladen met tellers; twee ongelezen (groene stip).
blocks/notification-feed/notification-feed.tsx
/**
* NotificationFeed — meldingen als kaart: een kop met titel en twee knoppen
* (alles gelezen, instellingen), tabbladen met tellers die de lijst filteren,
* en per melding een portret, wie-deed-wat-waar, tijdstip en hoe lang
* geleden, een ongelezen-stip, optioneel een citaat, een bestand met
* downloadknop of accepteren/afwijzen. Leeg: een belletje met tekst. Alles
* is data; de acties zijn callbacks.
*
* Geoogst uit 21st.dev Notifications Menu (ahmedmayara): radix Avatar/Tabs/
* Badge/Card + cva → tabs (FC-0120), button (FC-0003) en initialen;
* bg-emerald-500 → --success; de zes vaste meldingen → items-prop; de vaste
* filters 'Verified/Mentions' → tabs-prop met eigen filterfunctie.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../../primitives/ui/tabs";
export interface FeedNotification {
id: string | number;
/** Vrij trefwoord waarop tabs filteren (comment, follow, mention, …). */
kind?: string;
user: { name: string; avatar?: string };
action: React.ReactNode;
target?: React.ReactNode;
content?: React.ReactNode;
time: string;
timeAgo?: string;
read?: boolean;
file?: { name: string; size?: string; type?: string; href?: string };
/** Toont accepteren/afwijzen. */
invite?: boolean;
}
export interface FeedTab {
id: string;
label: string;
filter?: (n: FeedNotification) => boolean;
}
export interface NotificationFeedProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
items: readonly FeedNotification[];
tabs?: readonly FeedTab[];
title?: React.ReactNode;
onMarkAllRead?: () => void;
onSettings?: () => void;
onAccept?: (n: FeedNotification) => void;
onDecline?: (n: FeedNotification) => void;
onDownload?: (n: FeedNotification) => void;
labels?: Partial<Record<"markAllRead" | "settings" | "accept" | "decline" | "download" | "empty" | "unread", string>>;
}
const initialen = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
function Melding({ n, t, onAccept, onDecline, onDownload }: { n: FeedNotification; t: Record<string, string>; onAccept?: (n: FeedNotification) => void; onDecline?: (n: FeedNotification) => void; onDownload?: (n: FeedNotification) => void }) {
return (
<li className="fp-feed__item" data-ongelezen={n.read === false ? "" : undefined}>
{n.user.avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- portret van het project
<img className="fp-feed__portret" src={n.user.avatar} alt="" />
) : (
<span className="fp-feed__portret fp-feed__portret--letters" aria-hidden="true">{initialen(n.user.name)}</span>
)}
<div className="fp-feed__inhoud">
<div className="fp-feed__regel">
<p className="fp-feed__tekst">
<strong>{n.user.name}</strong> <span className="fp-feed__actie">{n.action}</span>{n.target && <> <strong>{n.target}</strong></>}
</p>
{n.read === false && <span className="fp-feed__stip" role="img" aria-label={t.unread} />}
</div>
<div className="fp-feed__regel">
<time className="fp-feed__tijd" dateTime={n.time}>{n.time}</time>
{n.timeAgo && <span className="fp-feed__tijd">{n.timeAgo}</span>}
</div>
{n.content && <div className="fp-feed__citaat">{n.content}</div>}
{n.file && (
<div className="fp-feed__bestand">
<span className="fp-feed__bestandsicoon" aria-hidden="true">
<svg viewBox="0 0 40 40" width="34" height="34" fill="none"><path d="M30 39.25H10C7.1 39.25 4.75 36.9 4.75 34V6C4.75 3.1 7.1.75 10 .75h10.5c1.4 0 2.7.55 3.7 1.54l9.5 9.48c1 1 1.55 2.3 1.55 3.7V34c0 2.9-2.35 5.25-5.25 5.25Z" fill="var(--background)" stroke="var(--border)" strokeWidth="1.5" /><path d="M23 1v8a4 4 0 0 0 4 4h8" stroke="var(--border)" strokeWidth="1.5" /></svg>
{n.file.type && <span className="fp-feed__bestandstype">{n.file.type}</span>}
</span>
<div className="fp-feed__bestandsinfo">
<div className="fp-feed__bestandsnaam">{n.file.name}</div>
{(n.file.type || n.file.size) && <div className="fp-feed__tijd">{[n.file.type, n.file.size].filter(Boolean).join(" • ")}</div>}
</div>
{(onDownload || n.file.href) && (
n.file.href ? (
<a className="fp-feed__download" href={n.file.href} download aria-label={t.download}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v13M7 11l5 5 5-5M4 21h16" /></svg>
</a>
) : (
<Button variant="ghost" className="fp-feed__download" aria-label={t.download} onPress={() => onDownload?.(n)}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v13M7 11l5 5 5-5M4 21h16" /></svg>
</Button>
)
)}
</div>
)}
{n.invite && (
<div className="fp-feed__acties">
<Button variant="ghost" className="fp-feed__knop" onPress={() => onDecline?.(n)}>{t.decline}</Button>
<Button variant="primary" className="fp-feed__knop" onPress={() => onAccept?.(n)}>{t.accept}</Button>
</div>
)}
</div>
</li>
);
}
export function NotificationFeed({ items, tabs, title = "Je meldingen", onMarkAllRead, onSettings, onAccept, onDecline, onDownload, labels, className, ...rest }: NotificationFeedProps) {
const t = { markAllRead: "Alles als gelezen markeren", settings: "Instellingen", accept: "Accepteren", decline: "Afwijzen", download: "Downloaden", empty: "Nog geen meldingen.", unread: "Ongelezen", ...labels };
const [tab, setTab] = useState(tabs?.[0]?.id ?? "alles");
const huidig = tabs?.find((x) => x.id === tab);
const lijst = huidig?.filter ? items.filter(huidig.filter) : items;
return (
<div {...rest} className={["fp-feed", className].filter(Boolean).join(" ")}>
<div className="fp-feed__kop">
<div className="fp-feed__kopregel">
<h3 className="fp-feed__titel">{title}</h3>
<div className="fp-feed__kopknoppen">
{onMarkAllRead && (
<Button variant="ghost" className="fp-feed__icoonknop" aria-label={t.markAllRead} onPress={onMarkAllRead}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M2 12l4 4 8-9M12 16l8-9" /></svg>
</Button>
)}
{onSettings && (
<Button variant="ghost" className="fp-feed__icoonknop" aria-label={t.settings} onPress={onSettings}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></svg>
</Button>
)}
</div>
</div>
{tabs && tabs.length > 0 && (
<Tabs value={tab} onChange={setTab} className="fp-feed__tabs">
<TabsList size="sm">
{tabs.map((x) => {
const n = x.filter ? items.filter(x.filter).length : items.length;
return (
<TabsTrigger key={x.id} value={x.id}>
{x.label} <span className="fp-feed__teller">{n}</span>
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
)}
</div>
{lijst.length > 0 ? (
<ul className="fp-feed__lijst">
{lijst.map((n) => <Melding key={n.id} n={n} t={t} onAccept={onAccept} onDecline={onDecline} onDownload={onDownload} />)}
</ul>
) : (
<div className="fp-feed__leeg">
<span className="fp-feed__bel" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0" /></svg>
</span>
<p className="fp-feed__leegtekst">{t.empty}</p>
</div>
)}
</div>
);
}
blocks/notification-feed/notification-feed.css
/** NotificationFeed — importeren in de app-root (registry-site: app/globals.css). */
.fp-feed { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; max-width: 32.5rem; padding: 1.5rem; border-radius: calc(var(--radius) * 1.5); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); }
.fp-feed__kop { display: flex; flex-direction: column; gap: 1rem; }
.fp-feed__kopregel { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.fp-feed__titel { margin: 0; font: inherit; font-size: 1rem; font-weight: 600; line-height: 1; letter-spacing: -0.006em; }
.fp-feed__kopknoppen { display: flex; align-items: center; gap: 0.5rem; }
.fp-feed__icoonknop { width: 2rem; height: 2rem; padding: 0; justify-content: center; color: var(--muted-foreground); }
.fp-feed__teller { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; padding: 0 0.375rem; margin-left: 0.375rem; border-radius: 999px; background: color-mix(in srgb, var(--muted-foreground) 30%, transparent); font-size: 0.6875rem; font-weight: 600; }
.fp-feed__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.fp-feed__item { display: flex; gap: 0.75rem; padding: 1rem 0; border-top: 1px dashed var(--border); }
.fp-feed__item:first-child { padding-top: 0; border-top: 0; }
.fp-feed__item:last-child { padding-bottom: 0; }
.fp-feed__portret { width: 2.75rem; height: 2.75rem; flex: 0 0 auto; border-radius: 50%; object-fit: cover; background: var(--muted); box-shadow: 0 0 0 1px var(--border); }
.fp-feed__portret--letters { display: inline-flex; align-items: center; justify-content: center; font: inherit; font-size: 0.8125rem; font-weight: 600; color: var(--muted-foreground); }
.fp-feed__inhoud { display: flex; flex: 1 1 auto; flex-direction: column; gap: 0.5rem; min-width: 0; }
.fp-feed__regel { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.fp-feed__tekst { margin: 0; font: inherit; font-size: 0.875rem; line-height: 1.45; }
.fp-feed__tekst strong { font-weight: 500; }
.fp-feed__actie { color: var(--muted-foreground); }
.fp-feed__stip { display: inline-block; width: 0.375rem; height: 0.375rem; flex: 0 0 auto; border-radius: 50%; background: var(--success); }
.fp-feed__tijd { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-feed__citaat { padding: 0.625rem; border-radius: var(--radius); background: var(--muted); font: inherit; font-size: 0.875rem; line-height: 1.45; letter-spacing: -0.006em; }
.fp-feed__bestand { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: var(--radius); background: var(--muted); }
.fp-feed__bestandsicoon { position: relative; display: inline-flex; flex: 0 0 auto; }
.fp-feed__bestandstype { position: absolute; left: 0; bottom: 0.375rem; display: inline-flex; align-items: center; height: 1rem; padding: 0 0.1875rem; border-radius: calc(var(--radius) * 0.4); background: var(--primary); color: var(--primary-foreground); font-size: 0.6875rem; font-weight: 600; line-height: 1; }
.fp-feed__bestandsinfo { flex: 1 1 auto; min-width: 0; }
.fp-feed__bestandsnaam { font: inherit; font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-feed__download { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; flex: 0 0 auto; border-radius: var(--radius); color: var(--foreground); text-decoration: none; }
a.fp-feed__download { transition: background-color var(--motion-instant-duration) var(--motion-instant); }
a.fp-feed__download:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); }
a.fp-feed__download:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-feed__acties { display: flex; gap: 0.5rem; }
.fp-feed__knop { min-height: 1.75rem; padding: 0.25rem 0.75rem; font-size: 0.75rem; }
.fp-feed__leeg { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.625rem; padding: 3rem 0; text-align: center; }
.fp-feed__bel { display: inline-flex; padding: 1rem; border-radius: 50%; background: var(--muted); color: var(--muted-foreground); }
.fp-feed__leegtekst { margin: 0; font: inherit; font-size: 0.875rem; font-weight: 500; color: var(--muted-foreground); }