f1f1be21c7
Celebration micro-interactions: - SuccessToast: auto-dismissing pill toast (success/info/warning variants) - ConfettiBurst: pure CSS 20-particle confetti on project creation - Project wizard: confetti + toast on successful creation - Vacation approval/rejection: contextual toasts - Allocation status change: success toast - Button: active:scale-[0.97] press feedback on all variants Collapsible sidebar + responsive: - Desktop: toggle collapse (72px icons-only mode) with localStorage persistence - NavTooltip: hover labels on collapsed icons - Mobile: hamburger menu + slide-in overlay with backdrop - Auto-close sidebar on mobile navigation - Scroll-to-top on route change (smooth behavior) Hover polish + accessibility: - Table rows: animated left-border accent + hover-lift - Stat cards + widgets: hover elevation + border glow - Timeline blocks: scale(1.02) + shadow-md on hover - Smooth scroll globally with prefers-reduced-motion fallback - Filter chips: framer-motion scale+fade enter/exit - Dropdowns: scaleY origin-top reveal animation - Preferences modal: scale+fade entrance - Link underline: animated ::after width expansion on hover Co-Authored-By: claude-flow <ruv@ruv.net>
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useSession } from "next-auth/react";
|
|
import Link from "next/link";
|
|
import type { Route } from "next";
|
|
import { motion, useAnimationControls } from "framer-motion";
|
|
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`;
|
|
}
|
|
|
|
type TabKey = "all" | "tasks" | "reminders";
|
|
|
|
export function NotificationBell() {
|
|
const [open, setOpen] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const bellRef = useRef<HTMLButtonElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
|
const { data: session, status } = useSession();
|
|
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
|
|
|
const badgeControls = useAnimationControls();
|
|
const prevUnreadRef = useRef<number | null>(null);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
|
|
enabled: isAuthenticated,
|
|
refetchInterval: 60_000,
|
|
retry: false,
|
|
});
|
|
|
|
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
|
enabled: isAuthenticated,
|
|
refetchInterval: 60_000,
|
|
retry: false,
|
|
});
|
|
|
|
// Bounce badge when unread count increases
|
|
useEffect(() => {
|
|
if (prevUnreadRef.current !== null && unreadCount > prevUnreadRef.current) {
|
|
void badgeControls.start({
|
|
scale: [1, 1.3, 1],
|
|
transition: { duration: 0.3, ease: "easeInOut" },
|
|
});
|
|
}
|
|
prevUnreadRef.current = unreadCount;
|
|
}, [unreadCount, badgeControls]);
|
|
|
|
const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
|
|
|
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
|
{ limit: 20 },
|
|
{ enabled: open && isAuthenticated && activeTab === "all", retry: false },
|
|
);
|
|
|
|
const { data: tasks = [] } = trpc.notification.listTasks.useQuery(
|
|
{ status: "OPEN", limit: 10 },
|
|
{ enabled: open && isAuthenticated && activeTab === "tasks", retry: false },
|
|
);
|
|
|
|
const { data: reminders = [] } = trpc.notification.listReminders.useQuery(
|
|
{ limit: 10 },
|
|
{ enabled: open && isAuthenticated && activeTab === "reminders", retry: false },
|
|
);
|
|
|
|
const markRead = trpc.notification.markRead.useMutation({
|
|
onSuccess: () => {
|
|
void utils.notification.unreadCount.invalidate();
|
|
void utils.notification.list.invalidate();
|
|
},
|
|
});
|
|
|
|
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
|
onSuccess: () => {
|
|
void utils.notification.taskCounts.invalidate();
|
|
void utils.notification.listTasks.invalidate();
|
|
void utils.notification.unreadCount.invalidate();
|
|
void utils.notification.list.invalidate();
|
|
},
|
|
});
|
|
|
|
// Compute dropdown position when opening
|
|
const updatePosition = useCallback(() => {
|
|
if (!bellRef.current) return;
|
|
const rect = bellRef.current.getBoundingClientRect();
|
|
const panelHeight = 440; // approximate max height
|
|
let top = rect.top;
|
|
// If it would overflow the bottom, flip upward
|
|
if (top + panelHeight > window.innerHeight) {
|
|
top = Math.max(8, window.innerHeight - panelHeight - 8);
|
|
}
|
|
setDropdownPos({ top, left: rect.right + 8 });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) updatePosition();
|
|
}, [open, updatePosition]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleClick(e: MouseEvent) {
|
|
const target = e.target as Node;
|
|
if (
|
|
ref.current && !ref.current.contains(target) &&
|
|
dropdownRef.current && !dropdownRef.current.contains(target)
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [open]);
|
|
|
|
function handleMarkAllRead() {
|
|
if (!isAuthenticated) return;
|
|
markRead.mutate({});
|
|
}
|
|
|
|
function handleMarkOne(id: string) {
|
|
if (!isAuthenticated) return;
|
|
markRead.mutate({ id });
|
|
}
|
|
|
|
function handleTaskAction(id: string, taskStatus: string) {
|
|
updateTaskStatus.mutate({ id, status: taskStatus as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
|
}
|
|
|
|
const tabs: { key: TabKey; label: string }[] = [
|
|
{ key: "all", label: "All" },
|
|
{ key: "tasks", label: "Tasks" },
|
|
{ key: "reminders", label: "Reminders" },
|
|
];
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
{/* Bell button */}
|
|
<button
|
|
ref={bellRef}
|
|
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>
|
|
{/* Unread notification badge (red) */}
|
|
{unreadCount > 0 && (
|
|
<motion.span
|
|
animate={badgeControls}
|
|
className="absolute top-0.5 right-0.5 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}
|
|
</motion.span>
|
|
)}
|
|
{/* Task count badge (orange) */}
|
|
{openTaskCount > 0 && (
|
|
<span className="absolute -bottom-0.5 right-0.5 flex items-center justify-center min-w-[14px] h-3.5 px-0.5 text-[9px] font-bold text-white bg-orange-500 rounded-full leading-none">
|
|
{openTaskCount > 99 ? "99+" : openTaskCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
|
|
{open && createPortal(
|
|
<motion.div
|
|
ref={dropdownRef}
|
|
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
|
|
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
|
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
|
|
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
|
>
|
|
{/* 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 && activeTab === "all" && (
|
|
<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>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-gray-100 dark:border-gray-800">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
|
|
activeTab === tab.key
|
|
? "text-brand-600 dark:text-brand-400 border-b-2 border-brand-600 dark:border-brand-400"
|
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
{tab.key === "tasks" && openTaskCount > 0 && (
|
|
<span className="ml-1 inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
|
{openTaskCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="max-h-80 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
|
{activeTab === "all" && (
|
|
<>
|
|
{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>
|
|
);
|
|
})
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "tasks" && (
|
|
<>
|
|
{tasks.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
|
No open tasks
|
|
</div>
|
|
) : (
|
|
tasks.map((t) => (
|
|
<div key={t.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug truncate">
|
|
{t.title}
|
|
</p>
|
|
{t.dueDate && (
|
|
<p className={`mt-0.5 text-xs ${new Date(t.dueDate).getTime() < Date.now() ? "text-red-600 dark:text-red-400 font-semibold" : "text-gray-400 dark:text-gray-500"}`}>
|
|
Due: {new Date(t.dueDate).toLocaleDateString("en-GB")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTaskAction(t.id, "DONE")}
|
|
disabled={updateTaskStatus.isPending}
|
|
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
|
|
title="Mark as Done"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTaskAction(t.id, "DISMISSED")}
|
|
disabled={updateTaskStatus.isPending}
|
|
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
|
title="Dismiss"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "reminders" && (
|
|
<>
|
|
{reminders.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
|
No reminders
|
|
</div>
|
|
) : (
|
|
reminders.map((r) => (
|
|
<div key={r.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
|
{r.title}
|
|
</p>
|
|
{r.body && (
|
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
|
{r.body}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
{r.nextRemindAt
|
|
? new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: relativeTime(new Date(r.createdAt))}
|
|
{r.recurrence && (
|
|
<span className="ml-2 text-brand-500 dark:text-brand-400">{r.recurrence}</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t border-gray-100 dark:border-gray-800 px-4 py-2">
|
|
<Link
|
|
href={"/notifications" as Route}
|
|
onClick={() => setOpen(false)}
|
|
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
|
>
|
|
View all →
|
|
</Link>
|
|
</div>
|
|
</motion.div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|