mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
25
web/components/notifications/NotificationBadge.tsx
Normal file
25
web/components/notifications/NotificationBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NotificationBadge({ count, className }: NotificationBadgeProps) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1 flex items-center justify-center",
|
||||
"min-w-[16px] h-4 px-1 rounded-full",
|
||||
"bg-brand-500 text-white text-[10px] font-bold leading-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
185
web/components/notifications/NotificationCenter.tsx
Normal file
185
web/components/notifications/NotificationCenter.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Bell, CheckCheck, Trash2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { NotificationBadge } from "./NotificationBadge";
|
||||
import { NotificationItem } from "./NotificationItem";
|
||||
import type { NotificationCategory } from "@/lib/notifications";
|
||||
|
||||
type FilterCategory = "all" | NotificationCategory;
|
||||
|
||||
const FILTER_TABS: { key: FilterCategory; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "error", label: "Errors" },
|
||||
{ key: "activity", label: "Activity" },
|
||||
{ key: "system", label: "System" },
|
||||
];
|
||||
|
||||
export function NotificationCenter() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterCategory>("all");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { notifications, unreadCount, markRead, markAllRead, clearHistory } =
|
||||
useNotifications();
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered =
|
||||
activeFilter === "all"
|
||||
? notifications
|
||||
: notifications.filter((n) => n.category === activeFilter);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setIsOpen((o) => !o)}
|
||||
className={cn(
|
||||
"relative p-1.5 rounded-md transition-colors",
|
||||
"text-surface-400 hover:text-surface-100 hover:bg-surface-800",
|
||||
isOpen && "bg-surface-800 text-surface-100"
|
||||
)}
|
||||
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<NotificationBadge count={unreadCount} />
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-2 z-50",
|
||||
"w-80 rounded-lg border border-surface-700 shadow-2xl",
|
||||
"bg-surface-900 overflow-hidden",
|
||||
"flex flex-col"
|
||||
)}
|
||||
style={{ maxHeight: "480px" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
|
||||
<h3 className="text-sm font-semibold text-surface-100">Notifications</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Mark all as read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex border-b border-surface-800 px-4">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const count =
|
||||
tab.key === "all"
|
||||
? notifications.length
|
||||
: notifications.filter((n) => n.category === tab.key).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveFilter(tab.key)}
|
||||
className={cn(
|
||||
"relative px-2 py-2.5 text-xs font-medium transition-colors mr-1",
|
||||
activeFilter === tab.key
|
||||
? "text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 text-[10px] px-1 py-0.5 rounded-full",
|
||||
activeFilter === tab.key
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-surface-700 text-surface-400"
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{activeFilter === tab.key && (
|
||||
<motion.div
|
||||
layoutId="notification-tab-indicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-500"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Bell className="w-8 h-8 text-surface-700" />
|
||||
<p className="text-sm text-surface-500">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-surface-800/60">
|
||||
{filtered.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={markRead}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/components/notifications/NotificationItem.tsx
Normal file
99
web/components/notifications/NotificationItem.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { XCircle, Zap, Settings, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import type { NotificationItem as NotificationItemType } from "@/lib/notifications";
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG = {
|
||||
error: {
|
||||
icon: XCircle,
|
||||
iconColor: "text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
activity: {
|
||||
icon: Zap,
|
||||
iconColor: "text-brand-400",
|
||||
bgColor: "bg-brand-500/10",
|
||||
},
|
||||
system: {
|
||||
icon: Settings,
|
||||
iconColor: "text-surface-400",
|
||||
bgColor: "bg-surface-700/40",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||
const config = CATEGORY_CONFIG[notification.category];
|
||||
const Icon = config.icon;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!notification.read) {
|
||||
onMarkRead(notification.id);
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 px-4 py-3 transition-colors cursor-pointer",
|
||||
"hover:bg-surface-800/60",
|
||||
!notification.read && "bg-surface-800/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center",
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-3.5 h-3.5", config.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm leading-snug",
|
||||
notification.read ? "text-surface-300" : "text-surface-100 font-medium"
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{!notification.read && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-1" />
|
||||
)}
|
||||
{notification.link && (
|
||||
<ExternalLink className="w-3 h-3 text-surface-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-surface-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{notification.description}
|
||||
</p>
|
||||
<p className="text-xs text-surface-600 mt-1">
|
||||
{formatDate(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (notification.link) {
|
||||
return (
|
||||
<a href={notification.link} className="block no-underline">
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
182
web/components/notifications/Toast.tsx
Normal file
182
web/components/notifications/Toast.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToastItem } from "@/lib/notifications";
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastItem;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
success: {
|
||||
border: "border-green-800",
|
||||
bg: "bg-green-950/90",
|
||||
icon: CheckCircle2,
|
||||
iconColor: "text-green-400",
|
||||
progress: "bg-green-500",
|
||||
},
|
||||
error: {
|
||||
border: "border-red-800",
|
||||
bg: "bg-red-950/90",
|
||||
icon: XCircle,
|
||||
iconColor: "text-red-400",
|
||||
progress: "bg-red-500",
|
||||
},
|
||||
warning: {
|
||||
border: "border-yellow-800",
|
||||
bg: "bg-yellow-950/90",
|
||||
icon: AlertTriangle,
|
||||
iconColor: "text-yellow-400",
|
||||
progress: "bg-yellow-500",
|
||||
},
|
||||
info: {
|
||||
border: "border-blue-800",
|
||||
bg: "bg-blue-950/90",
|
||||
icon: Info,
|
||||
iconColor: "text-blue-400",
|
||||
progress: "bg-blue-500",
|
||||
},
|
||||
loading: {
|
||||
border: "border-surface-700",
|
||||
bg: "bg-surface-800",
|
||||
icon: Loader2,
|
||||
iconColor: "text-brand-400",
|
||||
progress: "bg-brand-500",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function Toast({ toast, onDismiss }: ToastProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
|
||||
// Track remaining time across pause/resume cycles
|
||||
const remainingRef = useRef(toast.duration);
|
||||
|
||||
const dismiss = useCallback(() => onDismiss(toast.id), [onDismiss, toast.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration === 0) return; // loading: never auto-dismiss
|
||||
if (paused) return;
|
||||
|
||||
const snapRemaining = remainingRef.current;
|
||||
const start = Date.now();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - start;
|
||||
const newRemaining = Math.max(0, snapRemaining - elapsed);
|
||||
remainingRef.current = newRemaining;
|
||||
setProgress((newRemaining / toast.duration) * 100);
|
||||
|
||||
if (newRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
dismiss();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, toast.duration, dismiss]);
|
||||
|
||||
const config = VARIANT_CONFIG[toast.variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg shadow-xl border overflow-hidden",
|
||||
"w-80 pointer-events-auto backdrop-blur-sm",
|
||||
config.border,
|
||||
config.bg
|
||||
)}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3.5 pb-5">
|
||||
{/* Icon */}
|
||||
<div className={cn("mt-0.5 shrink-0", config.iconColor)}>
|
||||
<Icon
|
||||
className={cn("w-4 h-4", toast.variant === "loading" && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-100 leading-snug">
|
||||
{toast.title}
|
||||
</p>
|
||||
{toast.description && (
|
||||
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">
|
||||
{toast.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-brand-400 hover:text-brand-300 transition-colors"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expandable details */}
|
||||
{toast.details && (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3 h-3 transition-transform duration-150",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
{expanded ? "Hide details" : "Show details"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre className="mt-1.5 text-xs text-surface-400 bg-surface-900/80 rounded p-2 overflow-auto max-h-24 font-mono whitespace-pre-wrap break-all">
|
||||
{toast.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-surface-600 hover:text-surface-200 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{toast.duration > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-surface-700/50">
|
||||
<div
|
||||
className={cn("h-full transition-none", config.progress)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/components/notifications/ToastProvider.tsx
Normal file
12
web/components/notifications/ToastProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ToastStack } from "./ToastStack";
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ToastStack />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
web/components/notifications/ToastStack.tsx
Normal file
33
web/components/notifications/ToastStack.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Toast } from "./Toast";
|
||||
import { useNotificationStore } from "@/lib/notifications";
|
||||
|
||||
export function ToastStack() {
|
||||
const toasts = useNotificationStore((s) => s.toasts);
|
||||
const dismissToast = useNotificationStore((s) => s.dismissToast);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-label="Notifications"
|
||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 80, scale: 0.92 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 80, scale: 0.92, transition: { duration: 0.15 } }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
||||
>
|
||||
<Toast toast={toast} onDismiss={dismissToast} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user