feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates
This commit is contained in:
commit
e04672db2a
104 files changed
+8262
No files matched your search
@@ -0,0 +1,744 @@
|
||||
"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}
|
||||
className="flex-1 min-w-[34px] flex flex-col items-center gap-1 group relative"
|
||||
>
|
||||
{/* Hover Tooltip */}
|
||||
<div className="absolute -top-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none bg-black/90 border border-purple-700/60 rounded-lg px-2 py-1 text-[10px] font-mono text-white whitespace-nowrap z-20 shadow-lg">
|
||||
{day.date}: {day.total} total ({day.success} ok, {day.refused} refused, {day.error} err)
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="w-full bg-[#1e0e38] rounded-t-lg overflow-hidden flex flex-col justify-end" style={{ height: "100%" }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user