generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
36
.env.example
36
.env.example
@@ -1,12 +1,24 @@
|
|||||||
# NextAuth Configuration
|
# NextAuth Configuration
|
||||||
# Generate a secret with: openssl rand -base64 32
|
# Generate a secret with: openssl rand -base64 32
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3001
|
||||||
NEXTAUTH_SECRET=your-secret-key-here
|
NEXTAUTH_SECRET=your-secret-key-here
|
||||||
|
|
||||||
# Backend API URL
|
# Backend API URL (Next.js proxy üzerinden)
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
# Local: http://localhost:3001/api (proxy)
|
||||||
|
# Production: https://api.contentgen.ai/api
|
||||||
# Auth Mode: true = login required, false = public access with optional login
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
NEXT_PUBLIC_AUTH_REQUIRED=false
|
|
||||||
|
# WebSocket URL (Backend sunucu — proxy YOK, direkt)
|
||||||
NEXT_PUBLIC_GOOGLE_API_KEY='api-key'
|
# Local: http://localhost:3000
|
||||||
|
# Production: https://api.contentgen.ai
|
||||||
|
NEXT_PUBLIC_WS_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Auth Mode: true = login zorunlu, false = public + opsiyonel login
|
||||||
|
NEXT_PUBLIC_AUTH_REQUIRED=true
|
||||||
|
|
||||||
|
# Gemini AI (Frontend doğrudan kullanımı için, opsiyonel)
|
||||||
|
NEXT_PUBLIC_GOOGLE_API_KEY=your-google-api-key
|
||||||
|
|
||||||
|
# Mock mode: Backend olmadan arayüz geliştirmek için
|
||||||
|
# true = tüm API çağrıları mock verilerle yanıtlanır
|
||||||
|
NEXT_PUBLIC_ENABLE_MOCK_MODE=false
|
||||||
11216
package-lock.json
generated
Normal file
11216
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
src/app/[locale]/(dashboard)/dashboard/admin/layout.tsx
Normal file
36
src/app/[locale]/(dashboard)/dashboard/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Admin layout — sadece admin rolüne sahip kullanıcılar erişebilir
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCurrentUser } from "@/hooks/use-api";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data, isLoading } = useCurrentUser();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const user = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const roles: string[] = (user?.roles ?? []).map((r: any) => r?.role?.name ?? r?.name ?? "");
|
||||||
|
const isAdmin = roles.includes("admin") || roles.includes("superadmin");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAdmin) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [isLoading, isAdmin, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 size={32} className="animate-spin text-violet-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
253
src/app/[locale]/(dashboard)/dashboard/admin/page.tsx
Normal file
253
src/app/[locale]/(dashboard)/dashboard/admin/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
FolderOpen,
|
||||||
|
Cpu,
|
||||||
|
Coins,
|
||||||
|
LayoutGrid,
|
||||||
|
ArrowUpRight,
|
||||||
|
TrendingUp,
|
||||||
|
ShieldAlert,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAdminStats } from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const stagger = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.07 } },
|
||||||
|
};
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.45 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users, color: "violet" },
|
||||||
|
{ href: "/dashboard/admin/projects", label: "Projeler", icon: FolderOpen, color: "cyan" },
|
||||||
|
{ href: "/dashboard/admin/render-jobs", label: "Render İşler", icon: Cpu, color: "emerald" },
|
||||||
|
{ href: "/dashboard/admin/plans", label: "Planlar", icon: LayoutGrid, color: "amber" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function colorClass(color: string, type: "bg" | "text" | "icon") {
|
||||||
|
const map: Record<string, Record<string, string>> = {
|
||||||
|
violet: { bg: "from-violet-500/12 to-violet-600/5", text: "text-violet-400", icon: "bg-violet-500/12" },
|
||||||
|
cyan: { bg: "from-cyan-500/12 to-cyan-600/5", text: "text-cyan-400", icon: "bg-cyan-500/12" },
|
||||||
|
emerald: { bg: "from-emerald-500/12 to-emerald-600/5", text: "text-emerald-400", icon: "bg-emerald-500/12" },
|
||||||
|
amber: { bg: "from-amber-500/12 to-amber-600/5", text: "text-amber-400", icon: "bg-amber-500/12" },
|
||||||
|
rose: { bg: "from-rose-500/12 to-rose-600/5", text: "text-rose-400", icon: "bg-rose-500/12" },
|
||||||
|
};
|
||||||
|
return map[color]?.[type] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { data: statsData, isLoading } = useAdminStats();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const stats = (statsData as any)?.data ?? statsData;
|
||||||
|
|
||||||
|
const projectsByStatus = stats?.projects?.byStatus ?? {};
|
||||||
|
const renderByStatus = stats?.renderJobs?.byStatus ?? {};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: "Toplam Kullanıcı",
|
||||||
|
value: stats?.users?.total ?? "—",
|
||||||
|
sub: `${stats?.users?.active ?? 0} aktif`,
|
||||||
|
icon: Users,
|
||||||
|
color: "violet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Toplam Proje",
|
||||||
|
value: stats?.projects?.total ?? "—",
|
||||||
|
sub: `${projectsByStatus["COMPLETED"] ?? 0} tamamlandı`,
|
||||||
|
icon: FolderOpen,
|
||||||
|
color: "cyan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Aktif Render",
|
||||||
|
value: (renderByStatus["QUEUED"] ?? 0) + (renderByStatus["PROCESSING"] ?? 0),
|
||||||
|
sub: `${renderByStatus["COMPLETED"] ?? 0} tamamlandı`,
|
||||||
|
icon: Cpu,
|
||||||
|
color: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kredi Verildi",
|
||||||
|
value: stats?.credits?.totalGranted ?? "—",
|
||||||
|
sub: `${stats?.credits?.totalUsed ?? 0} kullanıldı`,
|
||||||
|
icon: Coins,
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Başarısız Job",
|
||||||
|
value: renderByStatus["FAILED"] ?? 0,
|
||||||
|
sub: "Müdahale gerekiyor",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: "rose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Şablon Sayısı",
|
||||||
|
value: stats?.templates?.total ?? "—",
|
||||||
|
sub: "Yayında",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
color: "violet",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={stagger} initial="hidden" animate="show" className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Başlık */}
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
|
Admin Paneli
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Sistem genelinde yönetim ve izleme
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-violet text-xs px-3 py-1">Süper Yönetici</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* İstatistik Kartları */}
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="col-span-6 flex justify-center py-12">
|
||||||
|
<Loader2 size={28} className="animate-spin text-violet-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
statCards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.label}
|
||||||
|
className={`card-surface p-4 bg-gradient-to-br ${colorClass(card.color, "bg")}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className={`w-8 h-8 rounded-xl ${colorClass(card.color, "icon")} flex items-center justify-center`}>
|
||||||
|
<Icon size={16} className={colorClass(card.color, "text")} />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight size={12} className="text-[var(--color-text-ghost)]" />
|
||||||
|
</div>
|
||||||
|
<div className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||||
|
{String(card.value)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-1 gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">{card.label}</span>
|
||||||
|
<span className="text-[9px] text-[var(--color-text-ghost)]">{card.sub}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Hızlı Yönetim Linkleri */}
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="group card-surface p-5 flex items-center gap-4 hover:border-violet-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={`w-11 h-11 rounded-xl bg-gradient-to-br ${
|
||||||
|
link.color === "violet" ? "from-violet-500 to-violet-700" :
|
||||||
|
link.color === "cyan" ? "from-cyan-500 to-cyan-700" :
|
||||||
|
link.color === "emerald" ? "from-emerald-500 to-emerald-700" :
|
||||||
|
"from-amber-500 to-amber-700"
|
||||||
|
} flex items-center justify-center shadow-lg`}>
|
||||||
|
<Icon size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">{link.label}</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">Yönet →</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Proje Durum Özeti */}
|
||||||
|
{!isLoading && stats && (
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="card-surface p-5">
|
||||||
|
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp size={14} className="text-violet-400" /> Proje Durumları
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(projectsByStatus as Record<string, number>).map(([status, count]) => (
|
||||||
|
<div key={status} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--color-text-muted)] capitalize">{status.replace("_", " ")}</span>
|
||||||
|
<span className="font-semibold">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-surface p-5">
|
||||||
|
<h2 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Cpu size={14} className="text-emerald-400" /> Render Job Durumları
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: "QUEUED", icon: Clock, color: "text-amber-400", label: "Beklemede" },
|
||||||
|
{ key: "PROCESSING", icon: Loader2, color: "text-cyan-400", label: "İşleniyor" },
|
||||||
|
{ key: "COMPLETED", icon: CheckCircle2, color: "text-emerald-400", label: "Tamamlandı" },
|
||||||
|
{ key: "FAILED", icon: XCircle, color: "text-rose-400", label: "Başarısız" },
|
||||||
|
].map(({ key, icon: Icon, color, label }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between text-sm">
|
||||||
|
<span className={`flex items-center gap-2 ${color}`}>
|
||||||
|
<Icon size={12} />
|
||||||
|
<span className="text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{renderByStatus[key] ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Son Kayıt Kullanıcılar */}
|
||||||
|
{!isLoading && stats?.recentUsers?.length > 0 && (
|
||||||
|
<motion.div variants={fadeUp} className="card-surface p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Users size={14} className="text-violet-400" /> Son Kayıt Kullanıcılar
|
||||||
|
</h2>
|
||||||
|
<Link href="/dashboard/admin/users" className="text-xs text-violet-400 hover:underline">
|
||||||
|
Tümünü Gör
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
{stats.recentUsers.map((user: any) => (
|
||||||
|
<div key={user.id} className="flex items-center justify-between py-2 border-b border-[var(--color-border-faint)] last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{user.firstName || user.lastName ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() : user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${user.isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-rose-500/10 text-rose-400"}`}>
|
||||||
|
{user.isActive ? "Aktif" : "Pasif"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-ghost)]">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("tr")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/app/[locale]/(dashboard)/dashboard/admin/plans/page.tsx
Normal file
184
src/app/[locale]/(dashboard)/dashboard/admin/plans/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { LayoutGrid, Save, Loader2, CheckCircle2, RefreshCw } from "lucide-react";
|
||||||
|
import { useAdminPlans, useAdminUpdatePlan } from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "number" | "text" | "toggle";
|
||||||
|
suffix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAN_FIELDS: PlanField[] = [
|
||||||
|
{ key: "displayName", label: "Görünen Ad", type: "text" },
|
||||||
|
{ key: "monthlyPrice", label: "Aylık Fiyat", type: "number", suffix: "$¢ (cent)" },
|
||||||
|
{ key: "yearlyPrice", label: "Yıllık Fiyat", type: "number", suffix: "$¢ (cent)" },
|
||||||
|
{ key: "monthlyCredits", label: "Aylık Kredi", type: "number" },
|
||||||
|
{ key: "maxDuration", label: "Max Süre", type: "number", suffix: "sn" },
|
||||||
|
{ key: "maxResolution", label: "Max Çözünürlük", type: "text" },
|
||||||
|
{ key: "maxProjects", label: "Max Proje", type: "number" },
|
||||||
|
{ key: "isActive", label: "Aktif", type: "toggle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPlansPage() {
|
||||||
|
const { data, isLoading, refetch } = useAdminPlans();
|
||||||
|
const updatePlan = useAdminUpdatePlan();
|
||||||
|
const [savedIds, setSavedIds] = useState<Set<string>>(new Set());
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [edits, setEdits] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rawPlans = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const plans = (Array.isArray(rawPlans) ? rawPlans : []) as any[];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function getValue(plan: any, key: string) {
|
||||||
|
return edits[plan.id]?.[key] !== undefined ? edits[plan.id][key] : plan[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function setEdit(planId: string, key: string, value: any) {
|
||||||
|
setEdits((prev) => ({ ...prev, [planId]: { ...prev[planId], [key]: value } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlan(planId: string) {
|
||||||
|
if (!edits[planId]) return;
|
||||||
|
updatePlan.mutate(
|
||||||
|
{ id: planId, data: edits[planId] },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSavedIds((prev) => new Set([...prev, planId]));
|
||||||
|
setTimeout(() => setSavedIds((prev) => { const n = new Set(prev); n.delete(planId); return n; }), 2000);
|
||||||
|
setEdits((prev) => { const n = { ...prev }; delete n[planId]; return n; });
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_COLORS: Record<string, string> = {
|
||||||
|
free: "from-gray-500/10 to-gray-600/5 border-gray-500/20",
|
||||||
|
pro: "from-violet-500/10 to-violet-600/5 border-violet-500/20",
|
||||||
|
business: "from-amber-500/10 to-amber-600/5 border-amber-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
variants={{ show: { transition: { staggerChildren: 0.07 } } }}
|
||||||
|
className="space-y-6 max-w-7xl mx-auto"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold flex items-center gap-2">
|
||||||
|
<LayoutGrid size={22} className="text-amber-400" /> Plan Yönetimi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Abonelik planlarını düzenle ve fiyatları güncelle
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className="btn-ghost text-sm flex items-center gap-2">
|
||||||
|
<RefreshCw size={14} /> Yenile
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 size={28} className="animate-spin text-amber-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const colorKey = plan.name?.toLowerCase() ?? "free";
|
||||||
|
const gradient = PLAN_COLORS[colorKey] ?? PLAN_COLORS.free;
|
||||||
|
const hasEdits = !!edits[plan.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={plan.id}
|
||||||
|
variants={fadeUp}
|
||||||
|
className={`card-surface p-5 bg-gradient-to-br ${gradient} space-y-4`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-xl font-bold capitalize">
|
||||||
|
{plan.name}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-[var(--color-text-ghost)]">
|
||||||
|
{plan._count?.subscriptions ?? 0} abone
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{PLAN_FIELDS.map((field) => {
|
||||||
|
const val = getValue(plan, field.key);
|
||||||
|
return (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="text-[10px] text-[var(--color-text-muted)] mb-1 block">
|
||||||
|
{field.label} {field.suffix && <span className="text-[var(--color-text-ghost)]">({field.suffix})</span>}
|
||||||
|
</label>
|
||||||
|
{field.type === "toggle" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setEdit(plan.id, field.key, !val)}
|
||||||
|
className={`flex items-center gap-2 text-sm px-3 py-1.5 rounded-xl ${
|
||||||
|
val ? "bg-emerald-500/10 text-emerald-400" : "bg-rose-500/10 text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val ? "✓ Aktif" : "✗ Pasif"}
|
||||||
|
</button>
|
||||||
|
) : field.type === "number" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val ?? ""}
|
||||||
|
onChange={(e) => setEdit(plan.id, field.key, Number(e.target.value))}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={val ?? ""}
|
||||||
|
onChange={(e) => setEdit(plan.id, field.key, e.target.value)}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => savePlan(plan.id)}
|
||||||
|
disabled={!hasEdits || updatePlan.isPending}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
savedIds.has(plan.id)
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: hasEdits
|
||||||
|
? "btn-primary"
|
||||||
|
: "opacity-40 cursor-not-allowed bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{savedIds.has(plan.id) ? (
|
||||||
|
<><CheckCircle2 size={14} /> Kaydedildi</>
|
||||||
|
) : updatePlan.isPending ? (
|
||||||
|
<><Loader2 size={14} className="animate-spin" /> Kaydediliyor…</>
|
||||||
|
) : (
|
||||||
|
<><Save size={14} /> Değişiklikleri Kaydet</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/app/[locale]/(dashboard)/dashboard/admin/projects/page.tsx
Normal file
200
src/app/[locale]/(dashboard)/dashboard/admin/projects/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAdminProjects, useAdminDeleteProject } from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
DRAFT: { label: "Taslak", color: "bg-gray-500/10 text-gray-400" },
|
||||||
|
GENERATING_SCRIPT: { label: "Senaryo Üretiliyor", color: "bg-blue-500/10 text-blue-400" },
|
||||||
|
PENDING: { label: "Kuyrukta", color: "bg-amber-500/10 text-amber-400" },
|
||||||
|
GENERATING_MEDIA: { label: "Medya Üretiliyor", color: "bg-cyan-500/10 text-cyan-400" },
|
||||||
|
RENDERING: { label: "Render", color: "bg-violet-500/10 text-violet-400" },
|
||||||
|
COMPLETED: { label: "Tamamlandı", color: "bg-emerald-500/10 text-emerald-400" },
|
||||||
|
FAILED: { label: "Başarısız", color: "bg-rose-500/10 text-rose-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminProjectsPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useAdminProjects({
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
});
|
||||||
|
const deleteProject = useAdminDeleteProject();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const response = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const projects = (response?.data ?? []) as any[];
|
||||||
|
const meta = response?.meta ?? {};
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? projects.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.user?.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: projects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
variants={{ show: { transition: { staggerChildren: 0.06 } } }}
|
||||||
|
className="space-y-5 max-w-7xl mx-auto"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold flex items-center gap-2">
|
||||||
|
<FolderOpen size={22} className="text-cyan-400" /> Proje Yönetimi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Toplam {meta.total ?? "—"} proje
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className="btn-ghost text-sm flex items-center gap-2">
|
||||||
|
<RefreshCw size={14} /> Yenile
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Filtreler */}
|
||||||
|
<motion.div variants={fadeUp} className="flex gap-3 flex-wrap">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Başlık veya email ara..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Tüm Durumlar</option>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([key, { label }]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeUp} className="card-surface overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--color-border-faint)]">
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Proje</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kullanıcı</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Durum</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kredi</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kaynak</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Tarih</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">İşlem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((project) => {
|
||||||
|
const st = STATUS_LABELS[project.status] ?? { label: project.status, color: "bg-gray-500/10 text-gray-400" };
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={project.id}
|
||||||
|
className="border-b border-[var(--color-border-faint)] last:border-0 hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium max-w-[200px] truncate">{project.title}</div>
|
||||||
|
<div className="text-[10px] text-[var(--color-text-ghost)]">{project.language} · {project._count?.scenes ?? 0} sahne</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{project.user?.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${st.color}`}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
{project.status !== "DRAFT" && project.status !== "COMPLETED" && project.status !== "FAILED" && (
|
||||||
|
<div className="w-16 h-1 rounded-full bg-[var(--color-border-faint)] mt-1">
|
||||||
|
<div className="h-full rounded-full bg-violet-500" style={{ width: `${project.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono">{project.creditsUsed}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-ghost)]">
|
||||||
|
{project.sourceType === "X_TWEET" ? "𝕏 Tweet" : project.sourceType === "YOUTUBE" ? "YouTube" : "Manuel"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{new Date(project.createdAt).toLocaleDateString("tr")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/projects/${project.id}`}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-cyan-500/10 text-cyan-400 transition-colors"
|
||||||
|
title="Detay"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`"${project.title}" projesini silmek istiyor musunuz?`)) {
|
||||||
|
deleteProject.mutate(project.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteProject.isPending}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-rose-400 transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{meta.totalPages > 1 && (
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-center gap-3">
|
||||||
|
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40">
|
||||||
|
<ChevronLeft size={14} /> Önceki
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">{page} / {meta.totalPages}</span>
|
||||||
|
<button onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))} disabled={page === meta.totalPages} className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40">
|
||||||
|
Sonraki <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Cpu,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAdminRenderJobs } from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||||||
|
QUEUED: { label: "Beklemede", color: "text-amber-400 bg-amber-500/10", icon: Clock },
|
||||||
|
PROCESSING: { label: "İşleniyor", color: "text-cyan-400 bg-cyan-500/10", icon: Loader2 },
|
||||||
|
COMPLETED: { label: "Tamamlandı", color: "text-emerald-400 bg-emerald-500/10", icon: CheckCircle2 },
|
||||||
|
FAILED: { label: "Başarısız", color: "text-rose-400 bg-rose-500/10", icon: XCircle },
|
||||||
|
CANCELLED: { label: "İptal", color: "text-gray-400 bg-gray-500/10", icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMs(ms?: number) {
|
||||||
|
if (!ms) return "—";
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return `${m}d ${s % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminRenderJobsPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useAdminRenderJobs({
|
||||||
|
page,
|
||||||
|
limit: 25,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const response = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const jobs = (response?.data ?? []) as any[];
|
||||||
|
const meta = response?.meta ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
variants={{ show: { transition: { staggerChildren: 0.06 } } }}
|
||||||
|
className="space-y-5 max-w-7xl mx-auto"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Cpu size={22} className="text-emerald-400" /> Render İş Takibi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Toplam {meta.total ?? "—"} render işi (15 saniyede bir güncellenir)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className="btn-ghost text-sm flex items-center gap-2">
|
||||||
|
<RefreshCw size={14} /> Yenile
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Durum Filtresi */}
|
||||||
|
<motion.div variants={fadeUp} className="flex gap-2 flex-wrap">
|
||||||
|
{[{ value: "", label: "Tümü" }, ...Object.entries(STATUS_CONFIG).map(([k, v]) => ({ value: k, label: v.label }))].map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => { setStatusFilter(value); setPage(1); }}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full transition-colors border ${
|
||||||
|
statusFilter === value
|
||||||
|
? "border-violet-500 bg-violet-500/10 text-violet-400"
|
||||||
|
: "border-[var(--color-border-faint)] text-[var(--color-text-muted)] hover:border-violet-500/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={fadeUp} className="card-surface overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--color-border-faint)]">
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Job ID</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Proje</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kullanıcı</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Durum</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Aşama</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Deneme</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Süre</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Tarih</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job) => {
|
||||||
|
const cfg = STATUS_CONFIG[job.status] ?? { label: job.status, color: "text-gray-400 bg-gray-500/10", icon: AlertTriangle };
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={job.id}
|
||||||
|
className="border-b border-[var(--color-border-faint)] last:border-0 hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{job.id.slice(0, 8)}…
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium max-w-[160px] truncate">{job.project?.title ?? "—"}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{job.project?.user?.email ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${cfg.color} flex items-center gap-1 w-fit`}>
|
||||||
|
<Icon size={10} className={job.status === "PROCESSING" ? "animate-spin" : ""} />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[10px] text-[var(--color-text-ghost)] capitalize">
|
||||||
|
{job.currentStage?.toLowerCase().replace("_", " ") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-center">{job.attemptNumber}</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono">{formatMs(job.processingTimeMs)}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{new Date(job.createdAt).toLocaleDateString("tr")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{meta.totalPages > 1 && (
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-center gap-3">
|
||||||
|
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1} className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40">
|
||||||
|
<ChevronLeft size={14} /> Önceki
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">{page} / {meta.totalPages}</span>
|
||||||
|
<button onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))} disabled={page === meta.totalPages} className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40">
|
||||||
|
Sonraki <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/app/[locale]/(dashboard)/dashboard/admin/users/page.tsx
Normal file
268
src/app/[locale]/(dashboard)/dashboard/admin/users/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
Coins,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useAdminUsers,
|
||||||
|
useToggleUserActive,
|
||||||
|
useGrantCredits,
|
||||||
|
} from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [grantModal, setGrantModal] = useState<{ userId: string; email: string } | null>(null);
|
||||||
|
const [creditAmount, setCreditAmount] = useState("10");
|
||||||
|
const [creditDesc, setCreditDesc] = useState("Admin tarafından manuel yükleme");
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useAdminUsers({ page, limit: 20 });
|
||||||
|
const toggleActive = useToggleUserActive();
|
||||||
|
const grantCredits = useGrantCredits();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const response = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const users = (response?.data ?? []) as any[];
|
||||||
|
const meta = response?.meta ?? {};
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.lastName?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
variants={{ show: { transition: { staggerChildren: 0.06 } } }}
|
||||||
|
className="space-y-5 max-w-7xl mx-auto"
|
||||||
|
>
|
||||||
|
{/* Başlık */}
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Users size={22} className="text-violet-400" /> Kullanıcı Yönetimi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Toplam {meta.total ?? "—"} kullanıcı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="btn-ghost text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> Yenile
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Arama */}
|
||||||
|
<motion.div variants={fadeUp} className="relative">
|
||||||
|
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Email, isim veya soyisim ara..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full max-w-md"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Tablo */}
|
||||||
|
<motion.div variants={fadeUp} className="card-surface overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 size={28} className="animate-spin text-violet-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--color-border-faint)]">
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kullanıcı</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Roller</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Durum</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">Kayıt</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs text-[var(--color-text-muted)] font-medium">İşlemler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((user) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const roleNames: string[] = (user.roles ?? []).map((r: any) => r?.role?.name ?? r?.name ?? "");
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="border-b border-[var(--color-border-faint)] last:border-0 hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">
|
||||||
|
{user.firstName || user.lastName
|
||||||
|
? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim()
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">{user.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{roleNames.length > 0 ? (
|
||||||
|
roleNames.map((role) => (
|
||||||
|
<span key={role} className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-400 flex items-center gap-1">
|
||||||
|
<Shield size={8} /> {role}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-text-ghost)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
user.isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "bg-rose-500/10 text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? "Aktif" : "Pasif"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("tr")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setGrantModal({ userId: user.id, email: user.email })}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-amber-500/10 text-amber-400 transition-colors"
|
||||||
|
title="Kredi Yükle"
|
||||||
|
>
|
||||||
|
<Coins size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive.mutate(user.id)}
|
||||||
|
disabled={toggleActive.isPending}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
user.isActive
|
||||||
|
? "hover:bg-rose-500/10 text-rose-400"
|
||||||
|
: "hover:bg-emerald-500/10 text-emerald-400"
|
||||||
|
}`}
|
||||||
|
title={user.isActive ? "Pasif Yap" : "Aktif Yap"}
|
||||||
|
>
|
||||||
|
{user.isActive ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Sayfalama */}
|
||||||
|
{meta.totalPages > 1 && (
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> Önceki
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{page} / {meta.totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
|
||||||
|
disabled={page === meta.totalPages}
|
||||||
|
className="btn-ghost text-sm flex items-center gap-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Sonraki <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kredi Yükleme Modal */}
|
||||||
|
{grantModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="card-surface p-6 w-full max-w-sm mx-4 space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-lg">Kredi Yükle</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{grantModal.email}</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--color-text-muted)] mb-1 block">Miktar</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={creditAmount}
|
||||||
|
onChange={(e) => setCreditAmount(e.target.value)}
|
||||||
|
className="input w-full"
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--color-text-muted)] mb-1 block">Açıklama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={creditDesc}
|
||||||
|
onChange={(e) => setCreditDesc(e.target.value)}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setGrantModal(null)}
|
||||||
|
className="btn-ghost flex-1"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
grantCredits.mutate(
|
||||||
|
{ userId: grantModal.userId, amount: Number(creditAmount), description: creditDesc },
|
||||||
|
{ onSuccess: () => { setGrantModal(null); refetch(); } }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={grantCredits.isPending}
|
||||||
|
className="btn-primary flex-1 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{grantCredits.isPending ? <Loader2 size={14} className="animate-spin" /> : <Coins size={14} />}
|
||||||
|
Yükle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign } from "lucide-react";
|
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||||
@@ -16,6 +16,8 @@ const navItems = [
|
|||||||
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const adminNavItem = { href: "/dashboard/admin", icon: ShieldCheck, label: "Admin Panel" };
|
||||||
|
|
||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||||
@@ -97,6 +99,12 @@ function CreditCard() {
|
|||||||
export function DesktopSidebar() {
|
export function DesktopSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||||
|
const { data } = useCurrentUser();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const user = (data as any)?.data ?? data;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const roles: string[] = (user?.roles ?? []).map((r: any) => r?.role?.name ?? r?.name ?? "");
|
||||||
|
const isAdmin = roles.includes("admin") || roles.includes("superadmin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden md:flex md:w-64 lg:w-72 flex-col h-screen sticky top-0 border-r border-[var(--color-border-faint)] bg-[var(--color-bg-deep)]">
|
<aside className="hidden md:flex md:w-64 lg:w-72 flex-col h-screen sticky top-0 border-r border-[var(--color-border-faint)] bg-[var(--color-bg-deep)]">
|
||||||
@@ -150,6 +158,24 @@ export function DesktopSidebar() {
|
|||||||
|
|
||||||
{/* Credits Card */}
|
{/* Credits Card */}
|
||||||
<CreditCard />
|
<CreditCard />
|
||||||
|
|
||||||
|
{/* Admin Panel Linki (sadece admin) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<Link
|
||||||
|
href={adminNavItem.href}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||||
|
localePath.startsWith("/dashboard/admin")
|
||||||
|
? "text-rose-300 bg-rose-500/10"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-rose-300 hover:bg-rose-500/8"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={18} strokeWidth={1.8} />
|
||||||
|
<span>{adminNavItem.label}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
dashboardApi,
|
dashboardApi,
|
||||||
xTwitterApi,
|
xTwitterApi,
|
||||||
notificationsApi,
|
notificationsApi,
|
||||||
|
adminApi,
|
||||||
type Project,
|
type Project,
|
||||||
type CreateProjectPayload,
|
type CreateProjectPayload,
|
||||||
type CreateFromTweetPayload,
|
type CreateFromTweetPayload,
|
||||||
@@ -49,6 +50,14 @@ export const queryKeys = {
|
|||||||
list: (params?: Record<string, unknown>) => ['notifications', 'list', params] as const,
|
list: (params?: Record<string, unknown>) => ['notifications', 'list', params] as const,
|
||||||
unreadCount: ['notifications', 'unread-count'] as const,
|
unreadCount: ['notifications', 'unread-count'] as const,
|
||||||
},
|
},
|
||||||
|
admin: {
|
||||||
|
stats: ['admin', 'stats'] as const,
|
||||||
|
users: (params?: Record<string, unknown>) => ['admin', 'users', params] as const,
|
||||||
|
projects: (params?: Record<string, unknown>) => ['admin', 'projects', params] as const,
|
||||||
|
renderJobs: (params?: Record<string, unknown>) => ['admin', 'render-jobs', params] as const,
|
||||||
|
plans: ['admin', 'plans'] as const,
|
||||||
|
roles: ['admin', 'roles'] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
@@ -350,3 +359,102 @@ export function useDeleteNotification() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// ADMIN — Yönetici hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Sistem istatistikleri */
|
||||||
|
export function useAdminStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.stats,
|
||||||
|
queryFn: () => adminApi.getStats(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tüm kullanıcılar (admin) */
|
||||||
|
export function useAdminUsers(params?: { page?: number; limit?: number }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.users(params),
|
||||||
|
queryFn: () => adminApi.getUsers(params),
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tüm projeler (admin) */
|
||||||
|
export function useAdminProjects(params?: { page?: number; limit?: number; status?: string; userId?: string }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.projects(params),
|
||||||
|
queryFn: () => adminApi.getProjects(params),
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tüm render joblar (admin) */
|
||||||
|
export function useAdminRenderJobs(params?: { page?: number; limit?: number; status?: string }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.renderJobs(params),
|
||||||
|
queryFn: () => adminApi.getRenderJobs(params),
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plan listesi (admin) */
|
||||||
|
export function useAdminPlans() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.plans,
|
||||||
|
queryFn: () => adminApi.getPlans(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kullanıcıya kredi yükle */
|
||||||
|
export function useGrantCredits() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, amount, description }: { userId: string; amount: number; description: string }) =>
|
||||||
|
adminApi.grantCredits(userId, { amount, description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.users() });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kullanıcı aktif/pasif toggle */
|
||||||
|
export function useToggleUserActive() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => adminApi.toggleUserActive(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.users() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plan güncelle */
|
||||||
|
export function useAdminUpdatePlan() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
adminApi.updatePlan(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.plans });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin proje sil */
|
||||||
|
export function useAdminDeleteProject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => adminApi.deleteProject(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.projects() });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.admin.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
190
src/hooks/use-websocket.ts
Normal file
190
src/hooks/use-websocket.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from './use-api';
|
||||||
|
|
||||||
|
// socket.io-client dinamik import (SSR güvenli)
|
||||||
|
let io: typeof import('socket.io-client').io | null = null;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('socket.io-client').then((mod) => {
|
||||||
|
io = mod.io;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_URL =
|
||||||
|
process.env.NEXT_PUBLIC_WS_URL ||
|
||||||
|
(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api').replace('/api', '');
|
||||||
|
|
||||||
|
type RenderProgressPayload = {
|
||||||
|
projectId: string;
|
||||||
|
progress: number;
|
||||||
|
stage: string;
|
||||||
|
stageLabel: string;
|
||||||
|
currentScene?: number;
|
||||||
|
totalScenes?: number;
|
||||||
|
eta?: number;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenderCompletedPayload = {
|
||||||
|
projectId: string;
|
||||||
|
finalVideoUrl: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
processingTimeMs: number;
|
||||||
|
fileSize: number;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenderFailedPayload = {
|
||||||
|
projectId: string;
|
||||||
|
error: string;
|
||||||
|
stage: string;
|
||||||
|
attemptNumber: number;
|
||||||
|
canRetry: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationPayload = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message?: string | null;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseWebSocketOptions {
|
||||||
|
userId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
onRenderProgress?: (payload: RenderProgressPayload) => void;
|
||||||
|
onRenderCompleted?: (payload: RenderCompletedPayload) => void;
|
||||||
|
onRenderFailed?: (payload: RenderFailedPayload) => void;
|
||||||
|
onNotification?: (payload: NotificationPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentGen WebSocket Hook
|
||||||
|
*
|
||||||
|
* Backend EventsGateway (/ws namespace) ile bağlantı kurar.
|
||||||
|
* Render ilerleme bildirimleri ve anlık notification push için kullanılır.
|
||||||
|
*
|
||||||
|
* Kullanım:
|
||||||
|
* - userId → join:user (bildirim room'u)
|
||||||
|
* - projectId → join:project (render progress room'u)
|
||||||
|
*/
|
||||||
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
|
const { userId, projectId, onRenderProgress, onRenderCompleted, onRenderFailed, onNotification } =
|
||||||
|
options;
|
||||||
|
const socketRef = useRef<ReturnType<typeof import('socket.io-client').io> | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (socketRef.current?.connected) return;
|
||||||
|
|
||||||
|
// io yüklenmemişse bekle
|
||||||
|
if (!io) {
|
||||||
|
const mod = await import('socket.io-client');
|
||||||
|
io = mod.io;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = io(`${WS_URL}/ws`, {
|
||||||
|
path: '/socket.io',
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
// Kullanıcı bildirim room'una katıl
|
||||||
|
if (userId) {
|
||||||
|
socket.emit('join:user', { userId });
|
||||||
|
}
|
||||||
|
// Proje render progress room'una katıl
|
||||||
|
if (projectId) {
|
||||||
|
socket.emit('join:project', { projectId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Render Progress ──────────────────────────────────────────────
|
||||||
|
socket.on('render:progress', (payload: RenderProgressPayload) => {
|
||||||
|
onRenderProgress?.(payload);
|
||||||
|
// Proje cache'ini invalide et (progress güncellemesi için)
|
||||||
|
if (payload.projectId) {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(payload.projectId) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('render:completed', (payload: RenderCompletedPayload) => {
|
||||||
|
onRenderCompleted?.(payload);
|
||||||
|
if (payload.projectId) {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(payload.projectId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('render:failed', (payload: RenderFailedPayload) => {
|
||||||
|
onRenderFailed?.(payload);
|
||||||
|
if (payload.projectId) {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(payload.projectId) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Notifications ────────────────────────────────────────────────
|
||||||
|
socket.on('notification:new', (payload: NotificationPayload) => {
|
||||||
|
onNotification?.(payload);
|
||||||
|
// Bildirim cache'lerini güncelle
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
// disconnect — reconnect otomatik yapılır
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', () => {
|
||||||
|
// Sessiz bağlantı hatası — reconnect retry yapılır
|
||||||
|
});
|
||||||
|
}, [userId, projectId, onRenderProgress, onRenderCompleted, onRenderFailed, onNotification, qc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
if (projectId) socket.emit('leave:project', { projectId });
|
||||||
|
if (userId) socket.emit('leave:user', { userId });
|
||||||
|
socket.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId, projectId]);
|
||||||
|
|
||||||
|
// Proje değişince odayı güncelle
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket?.connected && projectId) {
|
||||||
|
socket.emit('join:project', { projectId });
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket: socketRef.current,
|
||||||
|
isConnected: socketRef.current?.connected ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sadece kullanıcı bildirimleri için basit hook.
|
||||||
|
* Layout/Provider seviyesinde kullanılır.
|
||||||
|
*/
|
||||||
|
export function useNotificationSocket(userId?: string, onNotification?: (payload: NotificationPayload) => void) {
|
||||||
|
return useWebSocket({ userId, onNotification });
|
||||||
|
}
|
||||||
@@ -366,3 +366,126 @@ export const notificationsApi = {
|
|||||||
apiClient.delete(`/notifications/${id}`).then((r) => r.data),
|
apiClient.delete(`/notifications/${id}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Admin API Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
roles?: Array<{ role: { id: string; name: string } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProject {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
language: string;
|
||||||
|
sourceType: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; email: string; firstName?: string; lastName?: string };
|
||||||
|
_count: { scenes: number; renderJobs: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRenderJob {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
currentStage?: string;
|
||||||
|
attemptNumber: number;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
workerHostname?: string;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
user: { id: string; email: string; firstName?: string; lastName?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
monthlyPrice: number;
|
||||||
|
yearlyPrice?: number;
|
||||||
|
currency: string;
|
||||||
|
monthlyCredits: number;
|
||||||
|
maxDuration: number;
|
||||||
|
maxResolution: string;
|
||||||
|
maxProjects: number;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
features?: Record<string, unknown>;
|
||||||
|
_count: { subscriptions: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
users: { total: number; active: number; inactive: number };
|
||||||
|
projects: { total: number; byStatus: Record<string, number> };
|
||||||
|
renderJobs: { byStatus: Record<string, number>; active: number };
|
||||||
|
credits: { totalGranted: number; totalUsed: number };
|
||||||
|
plans: { total: number };
|
||||||
|
templates: { total: number };
|
||||||
|
storage?: unknown;
|
||||||
|
recentUsers: AdminUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin API Functions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getStats: () =>
|
||||||
|
apiClient.get<AdminStats>('/admin/stats').then((r) => r.data),
|
||||||
|
|
||||||
|
getUsers: (params?: { page?: number; limit?: number }) =>
|
||||||
|
apiClient.get<PaginatedResponse<AdminUser>>('/admin/users', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
getUserDetail: (id: string) =>
|
||||||
|
apiClient.get(`/admin/users/${id}/detail`).then((r) => r.data),
|
||||||
|
|
||||||
|
toggleUserActive: (id: string) =>
|
||||||
|
apiClient.put(`/admin/users/${id}/toggle-active`).then((r) => r.data),
|
||||||
|
|
||||||
|
banUser: (id: string) =>
|
||||||
|
apiClient.put(`/admin/users/${id}/ban`).then((r) => r.data),
|
||||||
|
|
||||||
|
activateUser: (id: string) =>
|
||||||
|
apiClient.put(`/admin/users/${id}/activate`).then((r) => r.data),
|
||||||
|
|
||||||
|
grantCredits: (userId: string, data: { amount: number; description: string }) =>
|
||||||
|
apiClient.post(`/admin/users/${userId}/credits`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
assignRole: (userId: string, roleId: string) =>
|
||||||
|
apiClient.post(`/admin/users/${userId}/roles/${roleId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
removeRole: (userId: string, roleId: string) =>
|
||||||
|
apiClient.delete(`/admin/users/${userId}/roles/${roleId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
getProjects: (params?: { page?: number; limit?: number; status?: string; userId?: string }) =>
|
||||||
|
apiClient.get<PaginatedResponse<AdminProject>>('/admin/projects', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteProject: (id: string) =>
|
||||||
|
apiClient.delete(`/admin/projects/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
getRenderJobs: (params?: { page?: number; limit?: number; status?: string }) =>
|
||||||
|
apiClient.get<PaginatedResponse<AdminRenderJob>>('/admin/render-jobs', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
getPlans: () =>
|
||||||
|
apiClient.get<AdminPlan[]>('/admin/plans').then((r) => r.data),
|
||||||
|
|
||||||
|
updatePlan: (id: string, data: Partial<AdminPlan>) =>
|
||||||
|
apiClient.put(`/admin/plans/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
getRoles: () =>
|
||||||
|
apiClient.get('/admin/roles').then((r) => r.data),
|
||||||
|
|
||||||
|
createRole: (data: { name: string; description?: string }) =>
|
||||||
|
apiClient.post('/admin/roles', data).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user