generated from fahricansecer/boilerplate-fe
This commit is contained in:
@@ -11,150 +11,183 @@ import {
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { useDashboardStats } from "@/hooks/use-api";
|
||||
|
||||
const weeklyData = [
|
||||
{ name: "Pzt", videos: 1, credits: 4 },
|
||||
{ name: "Sal", videos: 2, credits: 8 },
|
||||
{ name: "Çar", videos: 0, credits: 0 },
|
||||
{ name: "Per", videos: 3, credits: 12 },
|
||||
{ name: "Cum", videos: 1, credits: 4 },
|
||||
{ name: "Cmt", videos: 2, credits: 8 },
|
||||
{ name: "Paz", videos: 1, credits: 4 },
|
||||
];
|
||||
const COLORS = ["#8b5cf6", "#06b6d4", "#f59e0b", "#ef4444", "#10b981"];
|
||||
|
||||
const statusData = [
|
||||
{ name: "Tamamlanan", value: 8, color: "#34d399" },
|
||||
{ name: "Devam Eden", value: 2, color: "#8b5cf6" },
|
||||
{ name: "Taslak", value: 2, color: "#4a4a6a" },
|
||||
];
|
||||
function formatWeekData(stats: Record<string, unknown> | undefined) {
|
||||
if (!stats) {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (6 - i));
|
||||
return {
|
||||
name: d.toLocaleDateString("tr-TR", { weekday: "short" }),
|
||||
projects: 0,
|
||||
videos: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
const weeklyActivity = (stats as { weeklyActivity?: { name: string; projects: number; videos: number }[] }).weeklyActivity;
|
||||
if (weeklyActivity && Array.isArray(weeklyActivity)) return weeklyActivity;
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; dataKey: string; color: string }>;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="glass rounded-lg px-3 py-2 text-xs shadow-xl">
|
||||
<p className="text-[var(--color-text-muted)] mb-1">{label}</p>
|
||||
{payload.map((entry) => (
|
||||
<p key={entry.dataKey} className="text-[var(--color-text-primary)] font-medium">
|
||||
{entry.dataKey === "videos" ? "Video" : "Kredi"}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (6 - i));
|
||||
return {
|
||||
name: d.toLocaleDateString("tr-TR", { weekday: "short" }),
|
||||
projects: Math.floor(Math.random() * 5),
|
||||
videos: Math.floor(Math.random() * 3),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatPieData(stats: Record<string, unknown> | undefined) {
|
||||
if (!stats) return [];
|
||||
const statusBreakdown = (stats as { statusBreakdown?: { name: string; value: number }[] }).statusBreakdown;
|
||||
if (statusBreakdown && Array.isArray(statusBreakdown)) return statusBreakdown;
|
||||
|
||||
const totalProjects = (stats as { totalProjects?: number }).totalProjects ?? 0;
|
||||
const completedProjects = (stats as { completedProjects?: number }).completedProjects ?? 0;
|
||||
const activeProjects = (stats as { activeProjects?: number }).activeProjects ?? 0;
|
||||
const remaining = Math.max(0, totalProjects - completedProjects - activeProjects);
|
||||
|
||||
return [
|
||||
{ name: "Tamamlanan", value: completedProjects },
|
||||
{ name: "Devam Eden", value: activeProjects },
|
||||
{ name: "Bekleyen", value: remaining },
|
||||
].filter((d) => d.value > 0);
|
||||
}
|
||||
|
||||
export function DashboardCharts() {
|
||||
const { data, isLoading } = useDashboardStats();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stats = (data as any)?.data ?? data;
|
||||
const weekData = formatWeekData(stats);
|
||||
const pieData = formatPieData(stats);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="card p-5 h-[280px] animate-pulse bg-[var(--color-bg-surface)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* ── Haftalık Video Üretimi (Area Chart) ── */}
|
||||
<div className="lg:col-span-2 card-surface p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||
Haftalık Üretim
|
||||
</h2>
|
||||
<span className="badge badge-violet">Bu Hafta</span>
|
||||
</div>
|
||||
<div className="h-48 md:h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={weeklyData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradientViolet" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradientCyan" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="videos"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
fill="url(#gradientViolet)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="credits"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#gradientCyan)"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-violet-500" />
|
||||
Video
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500" />
|
||||
Kredi Kullanımı
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
{/* Haftalik Aktivite */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)] mb-4">
|
||||
Haftalık Aktivite
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={weekData}>
|
||||
<defs>
|
||||
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorVideos" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "var(--color-text-ghost)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(15,15,30,0.9)",
|
||||
border: "1px solid rgba(139,92,246,0.2)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="projects"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorProjects)"
|
||||
name="Projeler"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="videos"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorVideos)"
|
||||
name="Videolar"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* ── Proje Durumu (Donut Chart) ── */}
|
||||
<div className="card-surface p-5">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold mb-4">
|
||||
{/* Proje Durumu */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)] mb-4">
|
||||
Proje Durumu
|
||||
</h2>
|
||||
<div className="h-40 flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={65}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="space-y-2 mt-2">
|
||||
{statusData.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
</h3>
|
||||
{pieData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[200px] text-sm text-[var(--color-text-ghost)]">
|
||||
Henüz proje verisi yok
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width="50%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={70}
|
||||
innerRadius={40}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{pieData.map((_: unknown, index: number) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(15,15,30,0.9)",
|
||||
border: "1px solid rgba(139,92,246,0.2)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
}}
|
||||
/>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{item.value}</span>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
{pieData.map((item: { name: string; value: number }, idx: number) => (
|
||||
<div key={item.name} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-[var(--color-text-secondary)] ml-auto">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,196 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Download,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { FolderOpen, Clock, CheckCircle, Video, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProjects } from "@/hooks/use-api";
|
||||
|
||||
type ProjectStatus = "COMPLETED" | "RENDERING" | "GENERATING_MEDIA" | "DRAFT" | "FAILED";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ProjectStatus;
|
||||
progress: number;
|
||||
language: string;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Boötes Boşluğu — Evrenin En Büyük Gizemi",
|
||||
status: "COMPLETED",
|
||||
progress: 100,
|
||||
language: "tr",
|
||||
duration: 45,
|
||||
createdAt: "2 saat önce",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Kuantum Dolanıklık Nedir?",
|
||||
status: "RENDERING",
|
||||
progress: 72,
|
||||
language: "tr",
|
||||
duration: 60,
|
||||
createdAt: "30 dk önce",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "The Dark History of the Bermuda Triangle",
|
||||
status: "GENERATING_MEDIA",
|
||||
progress: 35,
|
||||
language: "en",
|
||||
duration: 40,
|
||||
createdAt: "15 dk önce",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Yapay Zeka Sanatı Öldürecek mi?",
|
||||
status: "DRAFT",
|
||||
progress: 0,
|
||||
language: "tr",
|
||||
duration: 55,
|
||||
createdAt: "1 gün önce",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "5 Misterios sin Resolver de la Historia",
|
||||
status: "FAILED",
|
||||
progress: 48,
|
||||
language: "es",
|
||||
duration: 50,
|
||||
createdAt: "3 saat önce",
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<
|
||||
ProjectStatus,
|
||||
{ label: string; icon: React.ElementType; badgeClass: string }
|
||||
> = {
|
||||
COMPLETED: { label: "Tamamlandı", icon: CheckCircle2, badgeClass: "badge-emerald" },
|
||||
RENDERING: { label: "Render", icon: Play, badgeClass: "badge-violet" },
|
||||
GENERATING_MEDIA: { label: "Medya Üretimi", icon: Clock, badgeClass: "badge-cyan" },
|
||||
DRAFT: { label: "Taslak", icon: FileText, badgeClass: "badge-amber" },
|
||||
FAILED: { label: "Başarısız", icon: AlertCircle, badgeClass: "badge-rose" },
|
||||
};
|
||||
|
||||
const flagEmoji: Record<string, string> = {
|
||||
tr: "🇹🇷",
|
||||
en: "🇺🇸",
|
||||
es: "🇪🇸",
|
||||
de: "🇩🇪",
|
||||
fr: "🇫🇷",
|
||||
ar: "🇸🇦",
|
||||
const statusMap: Record<string, { icon: typeof Clock; color: string; label: string }> = {
|
||||
draft: { icon: Clock, color: "text-amber-400", label: "Taslak" },
|
||||
scripting: { icon: Clock, color: "text-blue-400", label: "Senaryo" },
|
||||
reviewing: { icon: Clock, color: "text-purple-400", label: "İnceleme" },
|
||||
rendering: { icon: Video, color: "text-cyan-400", label: "Render" },
|
||||
completed: { icon: CheckCircle, color: "text-emerald-400", label: "Tamamlandı" },
|
||||
failed: { icon: Clock, color: "text-red-400", label: "Hatalı" },
|
||||
};
|
||||
|
||||
export function RecentProjects() {
|
||||
const { data, isLoading } = useProjects({ limit: 5 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projects = (data as any)?.data?.items ?? (data as any)?.data ?? (data as any)?.items ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
|
||||
Son Projeler
|
||||
</h3>
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 rounded-xl bg-[var(--color-bg-deep)] animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-surface overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
|
||||
Son Projeler
|
||||
</h2>
|
||||
</h3>
|
||||
<Link
|
||||
href="/dashboard/projects"
|
||||
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
||||
className="text-xs text-violet-400 hover:text-violet-300 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Tümünü gör →
|
||||
Tümü <ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--color-border-faint)]">
|
||||
{mockProjects.map((project, i) => {
|
||||
const config = statusConfig[project.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/projects/${project.id}`}
|
||||
className="group flex items-center gap-3 md:gap-4 px-4 md:px-5 py-3.5 hover:bg-[var(--color-bg-elevated)]/50 transition-colors"
|
||||
>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<span className="text-xl">{flagEmoji[project.language] || "🌍"}</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium truncate group-hover:text-violet-300 transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("badge text-[10px]", config.badgeClass)}>
|
||||
<StatusIcon size={10} className="mr-1" />
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{project.duration}s
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{project.createdAt}
|
||||
</span>
|
||||
{Array.isArray(projects) && projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FolderOpen
|
||||
size={32}
|
||||
className="text-[var(--color-text-ghost)] mb-2"
|
||||
/>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Henüz proje bulunmuyor
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/projects/new"
|
||||
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
İlk projenizi oluşturun →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(Array.isArray(projects) ? projects : []).map(
|
||||
(project: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}) => {
|
||||
const st = statusMap[project.status] ?? statusMap.draft;
|
||||
const StIcon = st.icon;
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/dashboard/projects/${project.id}`}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-bg-elevated)] border border-transparent hover:border-[var(--color-border-faint)] transition-all group"
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg bg-[var(--color-bg-deep)] flex items-center justify-center ${st.color}`}
|
||||
>
|
||||
<StIcon size={14} />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{project.progress > 0 && project.progress < 100 && (
|
||||
<div className="progress-bar mt-2 w-full max-w-[200px]">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden md:flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{project.status === "COMPLETED" && (
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
aria-label="İndir"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
aria-label="Sil"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-violet-300 transition-colors">
|
||||
{project.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{new Date(project.createdAt).toLocaleDateString("tr-TR")}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-medium px-2 py-0.5 rounded-full border ${st.color} border-current/20 bg-current/5`}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||
aria-label="Daha fazla"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{st.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Twitter,
|
||||
Loader2,
|
||||
Heart,
|
||||
Repeat2,
|
||||
@@ -18,6 +17,13 @@ import {
|
||||
Link2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
// X (Twitter) logosu — lucide-react'ta mevcut değil
|
||||
const XIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
import { useTweet } from "@/hooks/use-tweet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -95,7 +101,7 @@ export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||
<div className="w-9 h-9 rounded-xl bg-sky-500/15 flex items-center justify-center">
|
||||
<Twitter size={18} className="text-sky-400" />
|
||||
<XIcon size={18} className="text-sky-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
|
||||
@@ -194,7 +200,7 @@ export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Twitter size={16} className="text-sky-400" />
|
||||
<XIcon size={16} className="text-sky-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles } from "lucide-react";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||
import { NotificationsDropdown } from "./notifications-dropdown";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||
{ href: "/dashboard/x-to-video", icon: AtSign, label: "X → Video" },
|
||||
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
|
||||
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||
];
|
||||
@@ -58,6 +61,39 @@ export function MobileNav() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreditCard() {
|
||||
const { data, isLoading } = useCreditBalance();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const remaining = (data as any)?.data?.remaining ?? (data as any)?.remaining ?? 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const total = (data as any)?.data?.total ?? (data as any)?.total ?? 50;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const planName = (data as any)?.data?.plan ?? (data as any)?.plan ?? "Free";
|
||||
const pct = total > 0 ? Math.round((remaining / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mx-3 mb-4 p-4 rounded-xl bg-gradient-to-br from-violet-500/8 to-cyan-400/5 border border-[var(--color-border-faint)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Kalan Kredi</span>
|
||||
<span className="badge badge-violet">{planName}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||
{isLoading ? "..." : remaining}
|
||||
</div>
|
||||
<div className="progress-bar mt-2">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||
{total} kredilik planınızın {remaining}'{remaining === 1 ? "i" : "si"} kaldı
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopSidebar() {
|
||||
const pathname = usePathname();
|
||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||
@@ -113,25 +149,27 @@ export function DesktopSidebar() {
|
||||
</nav>
|
||||
|
||||
{/* Credits Card */}
|
||||
<div className="mx-3 mb-4 p-4 rounded-xl bg-gradient-to-br from-violet-500/8 to-cyan-400/5 border border-[var(--color-border-faint)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Kalan Kredi</span>
|
||||
<span className="badge badge-violet">Pro</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||
47
|
||||
</div>
|
||||
<div className="progress-bar mt-2">
|
||||
<div className="progress-bar-fill" style={{ width: "94%" }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||
50 kredilik planınızın 47'si kaldı
|
||||
</p>
|
||||
</div>
|
||||
<CreditCard />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar() {
|
||||
const { data } = useCurrentUser();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const user = (data as any)?.data ?? data;
|
||||
const initial = user?.firstName?.[0] ?? user?.email?.[0]?.toUpperCase() ?? "U";
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-cyan-500 flex items-center justify-center text-white text-sm font-semibold shadow-md"
|
||||
aria-label="Profil"
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 glass">
|
||||
@@ -151,25 +189,11 @@ export function TopBar() {
|
||||
|
||||
{/* Sağ taraf */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Bildirim */}
|
||||
<button
|
||||
className="relative w-9 h-9 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
aria-label="Bildirimler"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-violet-500 rounded-full" />
|
||||
</button>
|
||||
{/* Bildirimler — interaktif dropdown */}
|
||||
<NotificationsDropdown />
|
||||
|
||||
{/* Avatar */}
|
||||
<button
|
||||
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-cyan-500 flex items-center justify-center text-white text-sm font-semibold shadow-md"
|
||||
aria-label="Profil"
|
||||
>
|
||||
H
|
||||
</button>
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -190,3 +214,4 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Loader2, CheckCircle2, XCircle, Wifi, WifiOff } from 'lucide-react';
|
||||
import type { RenderProgressState } from '@/hooks/use-render-progress';
|
||||
|
||||
const STAGE_ORDER = ['tts', 'image_generation', 'music_generation', 'compositing', 'encoding'];
|
||||
|
||||
const STAGE_DETAILS: Record<string, { label: string; icon: string; color: string }> = {
|
||||
tts: { label: 'Seslendirme', icon: '🔊', color: 'from-violet-500 to-violet-600' },
|
||||
image_generation: { label: 'Görsel Üretim', icon: '🎨', color: 'from-cyan-500 to-cyan-600' },
|
||||
music_generation: { label: 'Müzik Üretim', icon: '🎵', color: 'from-amber-500 to-amber-600' },
|
||||
compositing: { label: 'Birleştirme', icon: '🎬', color: 'from-emerald-500 to-emerald-600' },
|
||||
encoding: { label: 'Kodlama', icon: '📦', color: 'from-rose-500 to-rose-600' },
|
||||
};
|
||||
|
||||
interface RenderProgressProps {
|
||||
renderState: RenderProgressState;
|
||||
}
|
||||
|
||||
export function RenderProgress({ renderState }: RenderProgressProps) {
|
||||
const { progress, stage, stageLabel, currentScene, totalScenes, eta, status, isConnected } = renderState;
|
||||
|
||||
if (status === 'idle') return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card-surface p-5 md:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{status === 'rendering' && (
|
||||
<Loader2 size={18} className="animate-spin text-violet-400" />
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<XCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{status === 'rendering' && 'Video Üretiliyor...'}
|
||||
{status === 'completed' && 'Video Hazır!'}
|
||||
{status === 'failed' && 'Üretim Başarısız'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* WebSocket bağlantı durumu */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi size={13} className="text-emerald-400" />
|
||||
) : (
|
||||
<WifiOff size={13} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{isConnected ? 'Canlı' : 'Bağlantı koptu'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ana Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{stageLabel}</span>
|
||||
<span className="text-xs font-mono font-semibold text-[var(--color-text-primary)]">
|
||||
%{Math.round(progress)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-violet-500 via-cyan-400 to-emerald-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aşama adımları */}
|
||||
{status === 'rendering' && (
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-3">
|
||||
{STAGE_ORDER.map((s) => {
|
||||
const detail = STAGE_DETAILS[s];
|
||||
const stageIndex = STAGE_ORDER.indexOf(s);
|
||||
const currentIndex = STAGE_ORDER.indexOf(stage);
|
||||
const isDone = stageIndex < currentIndex;
|
||||
const isCurrent = s === stage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s}
|
||||
className={`flex flex-col items-center gap-1 py-2 px-1 rounded-lg transition-all ${
|
||||
isCurrent
|
||||
? 'bg-violet-500/10 border border-violet-500/20'
|
||||
: isDone
|
||||
? 'bg-emerald-500/5'
|
||||
: 'opacity-40'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{detail.icon}</span>
|
||||
<span className="text-[9px] text-center text-[var(--color-text-ghost)] leading-tight">
|
||||
{detail.label}
|
||||
</span>
|
||||
{isDone && <CheckCircle2 size={10} className="text-emerald-400" />}
|
||||
{isCurrent && <Loader2 size={10} className="animate-spin text-violet-400" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alt bilgi */}
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--color-text-ghost)]">
|
||||
{currentScene > 0 && totalScenes > 0 && (
|
||||
<span>Sahne {currentScene}/{totalScenes}</span>
|
||||
)}
|
||||
{eta > 0 && status === 'rendering' && (
|
||||
<span>Tahmini kalan: ~{Math.ceil(eta / 60)} dk</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hata mesajı */}
|
||||
{status === 'failed' && renderState.error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-xs text-red-400">{renderState.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image, Mic } from 'lucide-react';
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: {
|
||||
id: string;
|
||||
order: number;
|
||||
title?: string;
|
||||
narrationText: string;
|
||||
visualPrompt: string;
|
||||
subtitleText?: string;
|
||||
duration: number;
|
||||
transitionType: string;
|
||||
mediaAssets?: Array<{ id: string; type: string; url?: string }>;
|
||||
};
|
||||
isEditable: boolean;
|
||||
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
|
||||
onRegenerate?: (sceneId: string) => void;
|
||||
isRegenerating?: boolean;
|
||||
}
|
||||
|
||||
export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegenerating }: SceneCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editNarration, setEditNarration] = useState(scene.narrationText);
|
||||
const [editVisual, setEditVisual] = useState(scene.visualPrompt);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate?.(scene.id, {
|
||||
narrationText: editNarration,
|
||||
visualPrompt: editVisual,
|
||||
subtitleText: editNarration,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditNarration(scene.narrationText);
|
||||
setEditVisual(scene.visualPrompt);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="card-surface p-4 md:p-5 hover:border-violet-500/20 transition-all duration-300">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500/20 to-violet-600/10 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-violet-400">{scene.order}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{scene.title || `Sahne ${scene.order}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<Clock size={10} /> {scene.duration}s
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<ArrowRight size={10} /> {scene.transitionType.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aksiyon butonları */}
|
||||
{isEditable && !isEditing && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-violet-400 hover:bg-violet-500/10 transition-colors"
|
||||
title="Düzenle"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRegenerate?.(scene.id)}
|
||||
disabled={isRegenerating}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-cyan-400 hover:bg-cyan-500/10 transition-colors disabled:opacity-40"
|
||||
title="AI ile yeniden üret"
|
||||
>
|
||||
<RefreshCw size={13} className={isRegenerating ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Narrasyon düzenleme */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
<Mic size={12} /> Narrasyon
|
||||
</label>
|
||||
<textarea
|
||||
value={editNarration}
|
||||
onChange={(e) => setEditNarration(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] resize-none focus:outline-none focus:ring-1 focus:ring-violet-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Görsel prompt düzenleme */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
<Image size={12} /> Görsel Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={editVisual}
|
||||
onChange={(e) => setEditVisual(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-secondary)] resize-none focus:outline-none focus:ring-1 focus:ring-cyan-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kaydet/İptal */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-500/15 text-violet-400 text-xs font-medium hover:bg-violet-500/25 transition-colors"
|
||||
>
|
||||
<Check size={13} /> Kaydet
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)] text-xs font-medium hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<X size={13} /> İptal
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="viewing" className="space-y-2.5">
|
||||
{/* Narrasyon */}
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-violet-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Mic size={11} className="text-violet-400" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{scene.narrationText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Görsel Prompt */}
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-cyan-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Image size={11} className="text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
|
||||
{scene.visualPrompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Medya önizleme (varsa) */}
|
||||
{scene.mediaAssets && scene.mediaAssets.length > 0 && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
{scene.mediaAssets.slice(0, 3).map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="w-16 h-16 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{asset.url ? (
|
||||
<img src={asset.url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Wand2 size={14} className="text-[var(--color-text-ghost)]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Sahne bağlantı çizgisi */}
|
||||
<div className="absolute left-7 -bottom-3 w-px h-3 bg-gradient-to-b from-[var(--color-border-faint)] to-transparent" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
Maximize2,
|
||||
Link2,
|
||||
CheckCircle2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ videoUrl, thumbnailUrl, title }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current;
|
||||
if (video) setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const video = videoRef.current;
|
||||
if (video) setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const time = Number(e.target.value);
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
videoRef.current?.requestFullscreen();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl;
|
||||
a.download = `${title || 'video'}.mp4`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(videoUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card-surface overflow-hidden"
|
||||
>
|
||||
{/* Video Container */}
|
||||
<div
|
||||
className="relative bg-black aspect-video cursor-pointer group"
|
||||
onMouseEnter={() => setShowControls(true)}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
poster={thumbnailUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Overlay Controls */}
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Play/Pause büyük ikon */}
|
||||
{!isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center" onClick={togglePlay}>
|
||||
<div className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md flex items-center justify-center border border-white/20 hover:bg-white/25 transition-colors">
|
||||
<Play size={28} className="text-white ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alt kontroller */}
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{/* Progress */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 appearance-none bg-white/20 rounded-full cursor-pointer accent-violet-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<span className="text-xs text-white/70 font-mono">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Aksiyon Butonları */}
|
||||
<div className="p-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow"
|
||||
>
|
||||
<Download size={15} /> İndir
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] text-sm font-medium hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 size={15} className="text-emerald-400" /> Kopyalandı
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 size={15} /> Link Kopyala
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||
import { ToastProvider } from "@/components/ui/toast";
|
||||
|
||||
export function Provider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -14,7 +15,7 @@ export function Provider({ children }: { children: React.ReactNode }) {
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ReactQueryProvider>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { CheckCircle, AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
||||
|
||||
type ToastVariant = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
variant: ToastVariant;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: (variant: ToastVariant, message: string, duration?: number) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null);
|
||||
|
||||
const icons: Record<ToastVariant, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
success:
|
||||
"bg-emerald-500/12 border-emerald-500/30 text-emerald-300 [--toast-icon:theme(colors.emerald.400)]",
|
||||
error:
|
||||
"bg-red-500/12 border-red-500/30 text-red-300 [--toast-icon:theme(colors.red.400)]",
|
||||
warning:
|
||||
"bg-amber-500/12 border-amber-500/30 text-amber-300 [--toast-icon:theme(colors.amber.400)]",
|
||||
info: "bg-violet-500/12 border-violet-500/30 text-violet-300 [--toast-icon:theme(colors.violet.400)]",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback(
|
||||
(variant: ToastVariant, message: string, duration = 4000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
setToasts((prev) => [...prev.slice(-4), { id, variant, message, duration }]);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
},
|
||||
[removeToast],
|
||||
);
|
||||
|
||||
const ctx: ToastContextType = {
|
||||
toast: addToast,
|
||||
success: (msg) => addToast("success", msg),
|
||||
error: (msg) => addToast("error", msg),
|
||||
warning: (msg) => addToast("warning", msg),
|
||||
info: (msg) => addToast("info", msg),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
{/* Toast container — fixed bottom-right */}
|
||||
<div className="fixed bottom-20 md:bottom-6 right-4 z-[9999] flex flex-col gap-2.5 max-w-[min(400px,calc(100vw-2rem))]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((t) => {
|
||||
const Icon = icons[t.variant];
|
||||
return (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ type: "spring", bounce: 0.25, duration: 0.4 }}
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-xl border backdrop-blur-xl shadow-2xl ${variantStyles[t.variant]}`}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className="shrink-0 mt-0.5"
|
||||
style={{ color: "var(--toast-icon)" }}
|
||||
/>
|
||||
<p className="text-sm font-medium flex-1 leading-snug">{t.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(t.id)}
|
||||
className="shrink-0 p-0.5 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={14} className="opacity-60" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextType {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useToast must be used within <ToastProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user