2026-10-28 01:20:01 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import {
|
|
|
|
|
Lock,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
Calendar,
|
|
|
|
|
Clock,
|
|
|
|
|
Sparkles,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
Download,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
ShieldAlert,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
XCircle,
|
|
|
|
|
BarChart3,
|
|
|
|
|
Layers,
|
|
|
|
|
LogOut,
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
Cpu,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
Activity,
|
|
|
|
|
Filter,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { StatsSummary } from "@/lib/storage";
|
|
|
|
|
|
|
|
|
|
export default function PortalPage() {
|
|
|
|
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
|
|
|
|
const [password, setPassword] = useState("");
|
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
|
const [authError, setAuthError] = useState("");
|
|
|
|
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Filter State
|
|
|
|
|
const [period, setPeriod] = useState<"day" | "week" | "month" | "all" | "custom">("week");
|
|
|
|
|
const [fromDate, setFromDate] = useState("");
|
|
|
|
|
const [toDate, setToDate] = useState("");
|
|
|
|
|
const [selectedStyleFilter, setSelectedStyleFilter] = useState("all");
|
|
|
|
|
|
|
|
|
|
// Dashboard Data State
|
|
|
|
|
const [stats, setStats] = useState<StatsSummary | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [activeTab, setActiveTab] = useState<"gallery" | "errors">("gallery");
|
|
|
|
|
|
|
|
|
|
// Check initial authentication
|
|
|
|
|
const checkAuth = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/portal/auth");
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
setIsAuthenticated(true);
|
|
|
|
|
} else {
|
|
|
|
|
setIsAuthenticated(false);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setIsAuthenticated(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
checkAuth();
|
|
|
|
|
}, [checkAuth]);
|
|
|
|
|
|
|
|
|
|
// Fetch statistics
|
|
|
|
|
const fetchStats = useCallback(async () => {
|
|
|
|
|
if (!isAuthenticated) return;
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
let url = `/api/portal/stats?period=${period}`;
|
|
|
|
|
if (period === "custom") {
|
|
|
|
|
if (fromDate) url += `&from=${fromDate}`;
|
|
|
|
|
if (toDate) url += `&to=${toDate}`;
|
|
|
|
|
}
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
if (res.status === 401) {
|
|
|
|
|
setIsAuthenticated(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (data.success && data.stats) {
|
|
|
|
|
setStats(data.stats);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to fetch stats:", err);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [isAuthenticated, period, fromDate, toDate]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isAuthenticated) {
|
|
|
|
|
fetchStats();
|
|
|
|
|
}
|
|
|
|
|
}, [isAuthenticated, fetchStats]);
|
|
|
|
|
|
|
|
|
|
// Handle Login
|
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setAuthError("");
|
|
|
|
|
setIsLoggingIn(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/portal/auth", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ password }),
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok && data.success) {
|
|
|
|
|
setIsAuthenticated(true);
|
|
|
|
|
setPassword("");
|
|
|
|
|
} else {
|
|
|
|
|
setAuthError(data.error || "Incorrect password");
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setAuthError("Network error. Please try again.");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoggingIn(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle Logout
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
await fetch("/api/portal/auth", { method: "DELETE" });
|
|
|
|
|
setIsAuthenticated(false);
|
|
|
|
|
setStats(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Filter gallery images by selected style dropdown
|
|
|
|
|
const displayedImages = (stats?.images || []).filter((img) => {
|
|
|
|
|
if (selectedStyleFilter === "all") return true;
|
|
|
|
|
return img.styleId === selectedStyleFilter;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Loading Initial Auth State
|
|
|
|
|
if (isAuthenticated === null) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-[#0d0716] flex items-center justify-center text-purple-200">
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
<RefreshCw className="w-6 h-6 animate-spin text-orange-500" />
|
|
|
|
|
<span className="font-semibold text-lg">Authenticating...</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Login Screen
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 overflow-y-auto bg-gradient-to-br from-[#0c0617] via-[#150a26] to-[#0c0617] flex items-center justify-center p-4">
|
|
|
|
|
<div className="w-full max-w-md bg-[#1a0e30]/90 backdrop-blur-xl border border-purple-900/40 rounded-3xl p-8 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] flex flex-col items-center text-center">
|
|
|
|
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-tr from-orange-600 to-amber-400 p-0.5 shadow-lg shadow-orange-500/30 flex items-center justify-center mb-6">
|
|
|
|
|
<div className="w-full h-full bg-[#160b2b] rounded-2xl flex items-center justify-center">
|
|
|
|
|
<Lock className="w-8 h-8 text-orange-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<h1 className="text-2xl font-black text-white tracking-wide">
|
|
|
|
|
万圣空手 · 管理后台
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm text-purple-300/80 mt-1 mb-6">
|
|
|
|
|
上海健志空手道馆 · Halloween Management & Gallery
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleLogin} className="w-full space-y-4">
|
|
|
|
|
<div className="relative text-left">
|
|
|
|
|
<input
|
|
|
|
|
type={showPassword ? "text" : "password"}
|
|
|
|
|
placeholder="Enter password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
autoFocus
|
|
|
|
|
className="w-full px-4 py-3.5 bg-[#0e071c] border border-purple-800/50 rounded-2xl text-white placeholder-purple-400/40 focus:outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-500/20 text-sm font-mono pr-12 transition-all"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
|
|
|
className="absolute right-3.5 top-3.5 text-purple-400 hover:text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{authError && (
|
|
|
|
|
<div className="p-3 bg-red-950/60 border border-red-800/60 rounded-xl text-red-300 text-xs font-semibold flex items-center justify-center space-x-2 animate-shake">
|
|
|
|
|
<AlertTriangle size={14} />
|
|
|
|
|
<span>{authError}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={isLoggingIn || !password}
|
|
|
|
|
className="w-full py-3.5 px-4 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-black font-extrabold text-sm rounded-2xl transition-all shadow-lg shadow-orange-500/25 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
|
|
|
|
>
|
|
|
|
|
{isLoggingIn ? (
|
|
|
|
|
<>
|
|
|
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
|
|
|
<span>Unlocking...</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span>Access Portal</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<div className="mt-6 pt-4 border-t border-purple-900/30 w-full">
|
|
|
|
|
<Link
|
|
|
|
|
href="/"
|
|
|
|
|
className="text-xs text-purple-400 hover:text-white flex items-center justify-center space-x-1 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft size={14} />
|
|
|
|
|
<span>Back to Photo Booth</span>
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main Portal Dashboard
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 overflow-y-auto overflow-x-hidden bg-[#0c0617] text-white flex flex-col font-sans">
|
|
|
|
|
{/* Top Navigation Bar */}
|
|
|
|
|
<header className="sticky top-0 z-30 flex-shrink-0 bg-[#140a27]/95 backdrop-blur-md border-b border-purple-900/40 px-4 sm:px-8 py-3.5 flex flex-wrap items-center justify-between gap-4">
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-orange-500 to-amber-400 p-0.5 shadow-md flex items-center justify-center">
|
|
|
|
|
<div className="w-full h-full bg-[#180d2f] rounded-[10px] flex items-center justify-center">
|
|
|
|
|
<Sparkles className="w-5 h-5 text-orange-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-base sm:text-lg font-black tracking-wide flex items-center space-x-2">
|
|
|
|
|
<span>万圣空手道馆 · Spooky Hand</span>
|
|
|
|
|
<span className="text-[10px] px-2 py-0.5 bg-orange-500/20 border border-orange-500/40 text-orange-300 rounded-full font-bold uppercase tracking-wider">
|
|
|
|
|
Portal
|
|
|
|
|
</span>
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-[11px] text-purple-300/70">
|
|
|
|
|
上海健志空手道馆 · Live statistics, error tracking & gallery
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fetchStats()}
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
title="Refresh statistics"
|
|
|
|
|
className="p-2.5 bg-purple-900/30 hover:bg-purple-800/40 border border-purple-700/40 rounded-xl text-purple-300 hover:text-white transition-all disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={16} className={isLoading ? "animate-spin" : ""} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<Link
|
|
|
|
|
href="/"
|
|
|
|
|
className="px-3.5 py-2 bg-purple-900/30 hover:bg-purple-800/40 border border-purple-700/40 rounded-xl text-purple-200 hover:text-white text-xs font-bold transition-all flex items-center space-x-1.5"
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft size={14} />
|
|
|
|
|
<span className="hidden sm:inline">Photo Booth</span>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLogout}
|
|
|
|
|
className="px-3.5 py-2 bg-red-950/40 hover:bg-red-900/60 border border-red-800/50 rounded-xl text-red-300 hover:text-white text-xs font-bold transition-all flex items-center space-x-1.5"
|
|
|
|
|
>
|
|
|
|
|
<LogOut size={14} />
|
|
|
|
|
<span>Logout</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Main Container */}
|
|
|
|
|
<main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8 space-y-6 pb-24">
|
|
|
|
|
{/* Period Filter Bar */}
|
|
|
|
|
<section className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-4 shadow-lg flex flex-wrap items-center justify-between gap-4">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Calendar size={16} className="text-orange-400" />
|
|
|
|
|
<span className="text-xs font-bold text-purple-200 uppercase tracking-wider">
|
|
|
|
|
Time Period:
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ id: "day", label: "Today" },
|
|
|
|
|
{ id: "week", label: "Last 7 Days" },
|
|
|
|
|
{ id: "month", label: "Last 30 Days" },
|
|
|
|
|
{ id: "all", label: "All Time" },
|
|
|
|
|
{ id: "custom", label: "Custom Range" },
|
|
|
|
|
] as const
|
|
|
|
|
).map((item) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.id}
|
|
|
|
|
onClick={() => setPeriod(item.id)}
|
|
|
|
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
|
|
|
|
period === item.id
|
|
|
|
|
? "bg-gradient-to-r from-orange-500 to-amber-500 text-black shadow-md shadow-orange-500/20 font-black"
|
|
|
|
|
: "bg-[#100620] hover:bg-purple-900/40 text-purple-300 border border-purple-900/40"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Custom Date Pickers */}
|
|
|
|
|
{period === "custom" && (
|
|
|
|
|
<div className="w-full sm:w-auto flex items-center space-x-2 pt-2 sm:pt-0 border-t sm:border-t-0 border-purple-900/40">
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
value={fromDate}
|
|
|
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
|
|
|
className="bg-[#0e071c] border border-purple-800/60 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-purple-400 text-xs">to</span>
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
value={toDate}
|
|
|
|
|
onChange={(e) => setToDate(e.target.value)}
|
|
|
|
|
className="bg-[#0e071c] border border-purple-800/60 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* KPI Summary Cards */}
|
|
|
|
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
|
|
{/* Card 1: Total Generations */}
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg relative overflow-hidden group">
|
|
|
|
|
<div className="flex items-center justify-between text-purple-300 mb-2">
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-wider">Total Generations</span>
|
|
|
|
|
<div className="p-2 bg-orange-500/10 rounded-xl border border-orange-500/20">
|
|
|
|
|
<BarChart3 className="w-4 h-4 text-orange-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-3xl font-black text-white">
|
|
|
|
|
{stats?.totalAttempts ?? 0}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs flex items-center space-x-2 text-purple-300/80">
|
|
|
|
|
<span className="text-emerald-400 font-bold flex items-center space-x-0.5">
|
|
|
|
|
<CheckCircle2 size={12} />
|
|
|
|
|
<span>{stats?.successCount ?? 0} success</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span>•</span>
|
|
|
|
|
<span className="text-purple-400">
|
|
|
|
|
{stats?.successRate ?? 0}% rate
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card 2: Average Generation Speed */}
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg relative overflow-hidden group">
|
|
|
|
|
<div className="flex items-center justify-between text-purple-300 mb-2">
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-wider">Avg Latency</span>
|
|
|
|
|
<div className="p-2 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
|
|
|
|
|
<Clock className="w-4 h-4 text-cyan-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-3xl font-black text-white">
|
|
|
|
|
{stats?.avgDurationMs ? `${(stats.avgDurationMs / 1000).toFixed(1)}s` : "0s"}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs text-purple-300/80">
|
|
|
|
|
End-to-end transformation time
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card 3: Safety Refusals */}
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg relative overflow-hidden group">
|
|
|
|
|
<div className="flex items-center justify-between text-purple-300 mb-2">
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-wider">Content Refusals</span>
|
|
|
|
|
<div className="p-2 bg-amber-500/10 rounded-xl border border-amber-500/20">
|
|
|
|
|
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-3xl font-black text-amber-400">
|
|
|
|
|
{stats?.refusedCount ?? 0}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs text-purple-300/80">
|
|
|
|
|
Filtered by SFW / safety guardrails
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card 4: Server / Provider Errors */}
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg relative overflow-hidden group">
|
|
|
|
|
<div className="flex items-center justify-between text-purple-300 mb-2">
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-wider">Server Errors</span>
|
|
|
|
|
<div className="p-2 bg-red-500/10 rounded-xl border border-red-500/20">
|
|
|
|
|
<XCircle className="w-4 h-4 text-red-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-3xl font-black text-red-400">
|
|
|
|
|
{stats?.errorCount ?? 0}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs text-purple-300/80">
|
|
|
|
|
API timeouts & network failures
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Analytics Breakdown Row: Daily Trend & Disguise Popularity */}
|
|
|
|
|
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
|
{/* Daily Trend Histogram */}
|
|
|
|
|
<div className="lg:col-span-2 bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg flex flex-col justify-between">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Activity size={16} className="text-orange-400" />
|
|
|
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-purple-200">
|
|
|
|
|
Generations by Day
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-purple-400 font-mono">
|
|
|
|
|
{stats?.dailyTrends?.length || 0} days shown
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{stats?.dailyTrends && stats.dailyTrends.length > 0 ? (
|
|
|
|
|
<div className="h-44 flex items-end gap-2 pt-6 pb-2 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
|
|
|
|
{(() => {
|
|
|
|
|
const maxCount = Math.max(
|
|
|
|
|
1,
|
|
|
|
|
...stats.dailyTrends.map((d) => d.total)
|
|
|
|
|
);
|
|
|
|
|
return stats.dailyTrends.map((day) => {
|
|
|
|
|
const heightPercent = Math.max(
|
|
|
|
|
8,
|
|
|
|
|
Math.round((day.total / maxCount) * 100)
|
|
|
|
|
);
|
|
|
|
|
const formattedDate = day.date.slice(5); // MM-DD
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={day.date}
|
2026-10-29 13:53:45 +08:00
|
|
|
className="flex-1 min-w-[34px] h-full flex flex-col items-center gap-1 group relative"
|
2026-10-29 13:57:39 +08:00
|
|
|
title={`${day.date}: ${day.total} total (${day.success} ok, ${day.refused} refused, ${day.error} err)`}
|
2026-10-28 01:20:01 +08:00
|
|
|
>
|
2026-10-29 13:57:39 +08:00
|
|
|
{/* Count (always visible, never clipped) */}
|
|
|
|
|
<span className="text-[11px] font-bold font-mono text-orange-300 leading-none">
|
|
|
|
|
{day.total}
|
|
|
|
|
</span>
|
2026-10-28 01:20:01 +08:00
|
|
|
|
|
|
|
|
{/* Bar */}
|
2026-10-29 13:53:45 +08:00
|
|
|
<div className="w-full flex-1 min-h-0 bg-[#1e0e38] rounded-t-lg overflow-hidden flex flex-col justify-end">
|
2026-10-28 01:20:01 +08:00
|
|
|
<div
|
|
|
|
|
style={{ height: `${heightPercent}%` }}
|
|
|
|
|
className="w-full bg-gradient-to-t from-orange-600 to-amber-400 rounded-t-lg transition-all group-hover:brightness-125"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Label */}
|
|
|
|
|
<span className="text-[10px] text-purple-400 font-mono">
|
|
|
|
|
{formattedDate}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-44 flex items-center justify-center text-purple-400/60 text-xs italic">
|
|
|
|
|
No generations recorded for this time range yet.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Popular Disguise Breakdown */}
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl p-5 shadow-lg flex flex-col">
|
|
|
|
|
<div className="flex items-center space-x-2 mb-4">
|
|
|
|
|
<Layers size={16} className="text-orange-400" />
|
|
|
|
|
<h3 className="text-sm font-bold uppercase tracking-wider text-purple-200">
|
|
|
|
|
Top Disguise Styles
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto max-h-52 pr-1">
|
|
|
|
|
{stats?.styleBreakdown && stats.styleBreakdown.length > 0 ? (
|
|
|
|
|
stats.styleBreakdown.map((style) => (
|
|
|
|
|
<div key={style.styleId} className="space-y-1">
|
|
|
|
|
<div className="flex items-center justify-between text-xs font-semibold">
|
|
|
|
|
<span className="text-purple-200 truncate pr-2">
|
|
|
|
|
{style.styleName}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-orange-400 font-mono">
|
|
|
|
|
{style.count} ({style.percentage}%)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full bg-[#100620] h-2 rounded-full overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="bg-gradient-to-r from-orange-500 to-amber-400 h-full rounded-full transition-all"
|
|
|
|
|
style={{ width: `${style.percentage}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-32 flex items-center justify-center text-purple-400/60 text-xs italic">
|
|
|
|
|
No styles used in this period.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Provider Health Chips */}
|
|
|
|
|
<div className="pt-4 mt-2 border-t border-purple-900/40 flex flex-wrap gap-2">
|
|
|
|
|
{stats?.providerBreakdown?.map((p) => (
|
|
|
|
|
<span
|
|
|
|
|
key={`${p.provider}-${p.model}`}
|
|
|
|
|
className="px-2.5 py-1 bg-purple-950/60 border border-purple-800/50 rounded-lg text-[10px] font-mono text-purple-300 flex items-center space-x-1"
|
|
|
|
|
>
|
|
|
|
|
<Cpu size={12} className="text-orange-400" />
|
|
|
|
|
<span>{p.provider}: {p.count}</span>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* View Tabs: Gallery vs Errors */}
|
|
|
|
|
<section className="space-y-4">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-purple-900/40 pb-3">
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveTab("gallery")}
|
|
|
|
|
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-xs font-black transition-all ${
|
|
|
|
|
activeTab === "gallery"
|
|
|
|
|
? "bg-purple-800/60 text-white border border-purple-600/60 shadow-md"
|
|
|
|
|
: "text-purple-300 hover:text-white bg-transparent"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<ImageIcon size={16} />
|
|
|
|
|
<span>Generated Images Gallery ({stats?.images?.length || 0})</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveTab("errors")}
|
|
|
|
|
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-xs font-black transition-all ${
|
|
|
|
|
activeTab === "errors"
|
|
|
|
|
? "bg-red-950/70 text-red-200 border border-red-800/70 shadow-md"
|
|
|
|
|
: "text-purple-300 hover:text-white bg-transparent"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<AlertTriangle size={16} />
|
|
|
|
|
<span>Error & Refusal Logs ({stats?.errorLogs?.length || 0})</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Style Filter for Gallery */}
|
|
|
|
|
{activeTab === "gallery" && (
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Filter size={14} className="text-purple-400" />
|
|
|
|
|
<select
|
|
|
|
|
value={selectedStyleFilter}
|
|
|
|
|
onChange={(e) => setSelectedStyleFilter(e.target.value)}
|
|
|
|
|
className="bg-[#0e071c] border border-purple-800/60 rounded-xl px-3 py-1.5 text-xs text-purple-200 focus:outline-none focus:border-orange-500"
|
|
|
|
|
>
|
|
|
|
|
<option value="all">All Disguises</option>
|
|
|
|
|
{stats?.styleBreakdown?.map((s) => (
|
|
|
|
|
<option key={s.styleId} value={s.styleId}>
|
|
|
|
|
{s.styleName} ({s.count})
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* TAB 1: Generated Images Gallery */}
|
|
|
|
|
{activeTab === "gallery" && (
|
|
|
|
|
<div>
|
|
|
|
|
{displayedImages.length > 0 ? (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
|
|
|
|
{displayedImages.map((img) => (
|
|
|
|
|
<div
|
|
|
|
|
key={img.id}
|
|
|
|
|
className="group relative bg-[#150a29] border border-purple-900/40 hover:border-orange-500/60 rounded-2xl overflow-hidden shadow-lg transition-all hover:-translate-y-1"
|
|
|
|
|
>
|
|
|
|
|
{/* Thumbnail Container */}
|
|
|
|
|
<a
|
|
|
|
|
href={img.imageUrl}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
title="Click to open image in new tab"
|
|
|
|
|
className="block aspect-[9/16] bg-black overflow-hidden relative"
|
|
|
|
|
>
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
<img
|
|
|
|
|
src={img.imageUrl}
|
|
|
|
|
alt={img.styleName}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Hover Overlay with Open in New Tab Icon */}
|
|
|
|
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
|
|
|
<div className="p-2.5 bg-orange-500/90 rounded-full text-black shadow-lg">
|
|
|
|
|
<ExternalLink size={18} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Provider Badge */}
|
|
|
|
|
<div className="absolute top-2 left-2 z-10">
|
|
|
|
|
<span
|
|
|
|
|
className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider shadow-md ${
|
|
|
|
|
img.provider === "gemini"
|
|
|
|
|
? "bg-cyan-500 text-black"
|
|
|
|
|
: img.provider === "openrouter"
|
|
|
|
|
? "bg-purple-500 text-white"
|
|
|
|
|
: "bg-amber-400 text-black"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{img.provider === "gemini" ? "Google Gemini" : img.provider === "openrouter" ? "OpenRouter" : "Demo Mode"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Latency Badge */}
|
|
|
|
|
{img.durationMs ? (
|
|
|
|
|
<div className="absolute top-2 right-2 z-10 px-1.5 py-0.5 rounded-md bg-black/70 text-[9px] font-mono text-purple-200">
|
|
|
|
|
{(img.durationMs / 1000).toFixed(1)}s
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
{/* Card Info */}
|
|
|
|
|
<div className="p-2.5 text-left space-y-1">
|
|
|
|
|
<div className="text-xs font-bold text-white truncate" title={img.styleName}>
|
|
|
|
|
{img.styleName}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[10px] text-purple-400 font-mono">
|
|
|
|
|
{new Date(img.timestamp).toLocaleString("en-CA", {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="pt-1 flex items-center justify-between text-[10px] border-t border-purple-900/30">
|
|
|
|
|
<span className="text-purple-400/80 font-mono truncate max-w-[100px]" title={img.model}>
|
|
|
|
|
{img.model}
|
|
|
|
|
</span>
|
|
|
|
|
<a
|
|
|
|
|
href={img.imageUrl}
|
|
|
|
|
download={`portrait-${img.id}.png`}
|
|
|
|
|
title="Download image"
|
|
|
|
|
className="text-purple-400 hover:text-orange-400 transition-colors p-1"
|
|
|
|
|
>
|
|
|
|
|
<Download size={13} />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="bg-[#150a29]/60 border border-purple-900/40 rounded-2xl p-12 text-center">
|
|
|
|
|
<ImageIcon className="w-12 h-12 text-purple-500/40 mx-auto mb-3" />
|
|
|
|
|
<p className="text-purple-300 font-semibold text-sm">
|
|
|
|
|
No generated portraits found for this selection.
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-purple-400/60 text-xs mt-1">
|
|
|
|
|
Snap photos in the booth to see them appear here live!
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* TAB 2: Error & Content Refusal Logs */}
|
|
|
|
|
{activeTab === "errors" && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{stats?.errorLogs && stats.errorLogs.length > 0 ? (
|
|
|
|
|
<div className="bg-[#150a29]/80 border border-purple-900/40 rounded-2xl overflow-hidden shadow-lg">
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-left text-xs font-sans">
|
|
|
|
|
<thead className="bg-[#0e061e] text-purple-300 uppercase tracking-wider font-mono text-[10px] border-b border-purple-900/40">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="py-3 px-4">Time</th>
|
|
|
|
|
<th className="py-3 px-4">Type</th>
|
|
|
|
|
<th className="py-3 px-4">Disguise</th>
|
|
|
|
|
<th className="py-3 px-4">Provider / Model</th>
|
|
|
|
|
<th className="py-3 px-4">Reason / Error Message</th>
|
|
|
|
|
<th className="py-3 px-4">Client ID</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-purple-900/30 font-mono text-purple-200">
|
|
|
|
|
{stats.errorLogs.map((log) => {
|
|
|
|
|
const isSafety = log.errorType === "safety_refusal" || log.status === "refused";
|
|
|
|
|
return (
|
|
|
|
|
<tr key={log.id} className="hover:bg-purple-900/20 transition-colors">
|
|
|
|
|
<td className="py-3 px-4 whitespace-nowrap text-purple-400">
|
|
|
|
|
{new Date(log.timestamp).toLocaleString("en-CA", {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
second: "2-digit",
|
|
|
|
|
})}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4 whitespace-nowrap">
|
|
|
|
|
<span
|
|
|
|
|
className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${
|
|
|
|
|
isSafety
|
|
|
|
|
? "bg-amber-950/80 border border-amber-600/50 text-amber-300"
|
|
|
|
|
: "bg-red-950/80 border border-red-600/50 text-red-300"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{isSafety ? "Safety Refusal" : "Server / API Error"}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4 font-sans font-semibold text-white">
|
|
|
|
|
{log.styleName}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4 whitespace-nowrap text-purple-300">
|
|
|
|
|
{log.provider} ({log.model})
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4 text-purple-200 font-sans max-w-md break-words">
|
|
|
|
|
{log.refusalReason || log.errorMessage || "Unknown error"}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4 text-purple-400/80">
|
|
|
|
|
{log.clientId || "127.0.0.1"}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="bg-[#150a29]/60 border border-purple-900/40 rounded-2xl p-12 text-center">
|
|
|
|
|
<CheckCircle2 className="w-12 h-12 text-emerald-400/40 mx-auto mb-3" />
|
|
|
|
|
<p className="text-purple-300 font-semibold text-sm">
|
|
|
|
|
No errors or content refusals recorded!
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-purple-400/60 text-xs mt-1">
|
|
|
|
|
Your photo booth is running smoothly.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|