chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
export function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
refetchInterval: 30_000,
});
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open },
);
const markRead = trpc.notification.markRead.useMutation({
onSuccess: () => {
void utils.notification.unreadCount.invalidate();
void utils.notification.list.invalidate();
},
});
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleMarkAllRead() {
markRead.mutate({});
}
function handleMarkOne(id: string) {
markRead.mutate({ id });
}
return (
<div ref={ref} className="relative">
{/* Bell button */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Notifications
</span>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
disabled={markRead.isPending}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline disabled:opacity-50"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No notifications yet
</div>
) : (
notifications.map((n) => {
const isUnread = n.readAt === null;
return (
<button
key={n.id}
type="button"
onClick={() => {
if (isUnread) handleMarkOne(n.id);
}}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
}`}
>
<div className="flex items-start gap-2">
{isUnread && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className={isUnread ? "" : "ml-4"}>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
{n.title}
</p>
{n.body && (
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{n.body}
</p>
)}
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{relativeTime(new Date(n.createdAt))}
</p>
</div>
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}