Files
ContentGen_FE/src/components/layout/notifications-dropdown.tsx
Harun CAN 8bd995ea18
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
main
2026-03-30 00:22:06 +03:00

255 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Bell, Check, CheckCheck, Trash2, Film, AlertTriangle, CreditCard, Info, X } from "lucide-react";
import {
useNotifications,
useUnreadNotificationCount,
useMarkNotificationAsRead,
useMarkAllNotificationsAsRead,
useDeleteNotification,
} from "@/hooks/use-api";
import { cn } from "@/lib/utils";
import type { Notification } from "@/lib/api/api-service";
/** Bildirim tipine göre ikon ve renk */
function getNotificationMeta(type: string) {
switch (type) {
case "render_complete":
return { icon: Film, color: "text-emerald-400", bg: "bg-emerald-500/12" };
case "render_failed":
return { icon: AlertTriangle, color: "text-red-400", bg: "bg-red-500/12" };
case "credit_low":
case "subscription_changed":
return { icon: CreditCard, color: "text-amber-400", bg: "bg-amber-500/12" };
default:
return { icon: Info, color: "text-violet-400", bg: "bg-violet-500/12" };
}
}
/** Tarih formatı — relative time */
function timeAgo(dateStr: string): string {
const now = Date.now();
const date = new Date(dateStr).getTime();
const diff = Math.max(0, now - date);
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "az önce";
if (minutes < 60) return `${minutes} dk önce`;
if (hours < 24) return `${hours} sa önce`;
if (days < 7) return `${days} gün önce`;
return new Date(dateStr).toLocaleDateString("tr-TR", { day: "numeric", month: "short" });
}
function NotificationItem({
notification,
onMarkRead,
onDelete,
}: {
notification: Notification;
onMarkRead: (id: string) => void;
onDelete: (id: string) => void;
}) {
const meta = getNotificationMeta(notification.type);
const Icon = meta.icon;
return (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
className={cn(
"group relative flex items-start gap-3 px-4 py-3 rounded-xl transition-colors cursor-default",
notification.isRead
? "opacity-60 hover:opacity-80"
: "bg-[var(--color-bg-elevated)] hover:bg-[var(--color-bg-surface)]"
)}
>
{/* İkon */}
<div className={cn("flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center mt-0.5", meta.bg)}>
<Icon size={16} className={meta.color} />
</div>
{/* İçerik */}
<div className="flex-1 min-w-0">
<p className={cn(
"text-sm leading-snug",
notification.isRead ? "text-[var(--color-text-muted)]" : "text-[var(--color-text-primary)] font-medium"
)}>
{notification.title}
</p>
{notification.message && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{notification.message}
</p>
)}
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1">
{timeAgo(notification.createdAt)}
</p>
</div>
{/* Aksiyonlar — hover'da göster */}
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.isRead && (
<button
onClick={(e) => { e.stopPropagation(); onMarkRead(notification.id); }}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
title="Okundu işaretle"
>
<Check size={14} />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(notification.id); }}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Sil"
>
<Trash2 size={14} />
</button>
</div>
{/* Okunmamış göstergesi */}
{!notification.isRead && (
<span className="absolute top-3 right-3 w-2 h-2 rounded-full bg-violet-500 group-hover:opacity-0 transition-opacity" />
)}
</motion.div>
);
}
export function NotificationsDropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: unreadData } = useUnreadNotificationCount();
const { data: notifData, isLoading } = useNotifications({ limit: 20 });
const markRead = useMarkNotificationAsRead();
const markAllRead = useMarkAllNotificationsAsRead();
const deleteNotif = useDeleteNotification();
// Okunmamış sayısı — global interceptor wrap edebilir
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unreadCount = (unreadData as any)?.data?.count ?? (unreadData as any)?.count ?? 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const notifications: Notification[] = (notifData as any)?.data?.data ?? (notifData as any)?.data ?? [];
// Dışarı tıklanınca kapat
const handleClickOutside = useCallback((e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}, []);
useEffect(() => {
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, handleClickOutside]);
return (
<div className="relative" ref={dropdownRef}>
{/* Tetikleyici Buton */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"relative w-9 h-9 rounded-xl flex items-center justify-center transition-colors",
isOpen
? "text-violet-400 bg-violet-500/10"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)]"
)}
aria-label="Bildirimler"
id="notifications-trigger"
>
<Bell size={18} strokeWidth={1.8} />
{/* Badge */}
<AnimatePresence>
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-violet-500 text-[10px] font-bold text-white shadow-lg shadow-violet-500/30"
>
{unreadCount > 99 ? "99+" : unreadCount}
</motion.span>
)}
</AnimatePresence>
</button>
{/* Dropdown 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={{ type: "spring", bounce: 0.15, duration: 0.35 }}
className="absolute right-0 top-full mt-2 w-[380px] max-h-[480px] rounded-2xl border border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] shadow-2xl shadow-black/30 overflow-hidden z-50"
style={{ backdropFilter: "blur(20px)" }}
>
{/* Başlık */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-faint)]">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
Bildirimler
</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllRead.mutate()}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-violet-400 hover:bg-violet-500/10 rounded-lg transition-colors"
disabled={markAllRead.isPending}
>
<CheckCheck size={13} />
Tümünü oku
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors md:hidden"
>
<X size={16} />
</button>
</div>
</div>
{/* Bildirim Listesi */}
<div className="overflow-y-auto max-h-[400px] p-2 space-y-1 scrollbar-thin">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<div className="w-6 h-6 border-2 border-violet-500/30 border-t-violet-500 rounded-full animate-spin" />
<p className="text-xs text-[var(--color-text-muted)]">Yükleniyor...</p>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<div className="w-12 h-12 rounded-2xl bg-[var(--color-bg-elevated)] flex items-center justify-center">
<Bell size={20} className="text-[var(--color-text-ghost)]" />
</div>
<p className="text-sm text-[var(--color-text-muted)]">Henüz bildirim yok</p>
<p className="text-xs text-[var(--color-text-ghost)]">
Video render ve sistem olayları burada görünecek
</p>
</div>
) : (
<AnimatePresence>
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={(id) => markRead.mutate(id)}
onDelete={(id) => deleteNotif.mutate(id)}
/>
))}
</AnimatePresence>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}