← alle entries

notification-feed

FC-0133Meldingennotification-feedblockv1.0.0

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).

zelfde component — andere tokens

Je meldingen

  • Amélie Peeters reageerde in Dashboard 2.0

    2 uur geleden
    Mooie aanpak — ik denk dat dit de beste oplossing is voor het synchronisatieprobleem.
  • Sanne Koster volgt je nu

    2 uur geleden
  • Amir Haddad nodigde je uit voor Blogontwerp

    3 uur geleden
  • Mathilde Vos deelde een bestand in Dashboard 2.0

    4 uur geleden
    Prototype-opname 01.mp4
    MP4 • 14 MB
  • Jasper de Wit noemde je in Project Alpha

    1 dag geleden
    Hé @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); }