feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates

This commit is contained in:
Ajasra committed 2026-10-28 01:20:01 +08:00
commit e04672db2a
104 files changed
+8262

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getBoothConfig, getBoothStyles } from "@/lib/config";
export async function GET() {
const config = getBoothConfig();
const styles = getBoothStyles();
// Return public safe configuration (omit any secret internal options)
return NextResponse.json({
appName: config.appName,
kioskMode: config.kioskMode,
theme: config.theme,
camera: config.camera,
features: config.features,
limits: config.limits,
kiosk: config.kiosk,
labels: config.labels,
styles,
});
}
+168
View File
@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { getBoothConfig, getBoothStyles } from "@/lib/config";
import { checkRateLimit, incrementRateLimit } from "@/lib/rate-limit";
import { orchestrateGeneration } from "@/lib/ai/orchestrator";
import { isContentSafetyError } from "@/lib/ai/safety";
import { logGenerationEvent } from "@/lib/storage";
import { validateAndSanitizeImage, getClientIp } from "@/lib/security";
export async function POST(req: NextRequest) {
const requestStartTime = Date.now();
let clientId = "127.0.0.1";
let selectedStyle: any = null;
let requestedStyleId = "";
try {
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid JSON request body" },
{ status: 400 }
);
}
const { imageBase64, styleId, clientId: clientProvidedId, mimeType } = body;
requestedStyleId = typeof styleId === "string" ? styleId.slice(0, 64) : "";
const config = getBoothConfig();
// 1. Image binary validation & magic bytes verification (rejects executables, scripts, etc.)
const maxUploadBytes = (config.camera.maxUploadSizeMb || 10) * 1024 * 1024;
const validation = validateAndSanitizeImage(imageBase64, maxUploadBytes);
if (!validation.valid) {
console.warn(`[Security Alert] Rejected image upload: ${validation.error}`);
return NextResponse.json(
{ error: validation.error || "Invalid or disallowed image file" },
{ status: 400 }
);
}
const styles = getBoothStyles();
// Find requested style
selectedStyle = styles.find((s) => s.id === styleId) || styles[0];
if (!selectedStyle) {
return NextResponse.json(
{ error: "No styles available in configuration" },
{ status: 400 }
);
}
// Determine client identifier for rate-limiting (bind strictly to verified IP)
const ip = getClientIp(req);
// Sanitize optional device token if provided, but always prefix with verified IP so clients cannot evade IP quotas
const safeClientTag = typeof clientProvidedId === "string"
? clientProvidedId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32)
: "";
clientId = safeClientTag ? `${ip}#${safeClientTag}` : ip;
// Check device rate limit
const limitCheck = checkRateLimit(
clientId,
config.limits.maxGenerationsPerDevice,
config.limits.cooldownMinutes
);
if (!limitCheck.allowed) {
// Record rate limit refusal
await logGenerationEvent({
styleId: selectedStyle.id,
styleName: selectedStyle.name,
provider: "unknown",
model: "rate_limiter",
durationMs: Date.now() - requestStartTime,
status: "refused",
errorType: "rate_limit",
errorMessage: "Device reached maximum generation quota within cooldown window.",
refusalReason: `Rate limit hit. Wait ${limitCheck.resetMinutes} min.`,
clientId,
});
return NextResponse.json(
{
error: "Rate limit reached for this device.",
remaining: 0,
resetMinutes: limitCheck.resetMinutes,
},
{ status: 429 }
);
}
// Execute generation pipeline with sanitized image and verified MIME type
const result = await orchestrateGeneration({
config,
style: selectedStyle,
imageBase64: validation.sanitizedBase64!,
mimeType: validation.mimeType,
});
// Save successful generation & image file to persistent storage
const record = await logGenerationEvent({
styleId: selectedStyle.id,
styleName: selectedStyle.name,
provider: result.providerUsed,
model: result.modelUsed,
durationMs: result.durationMs,
status: "success",
rawImageUrl: result.imageUrl,
clientId,
});
// Increment usage for this device
incrementRateLimit(clientId);
const updatedLimit = checkRateLimit(
clientId,
config.limits.maxGenerationsPerDevice,
config.limits.cooldownMinutes
);
const servedImageUrl = record.imageUrl || result.imageUrl;
return NextResponse.json({
success: true,
imageUrl: servedImageUrl,
providerUsed: result.providerUsed,
modelUsed: result.modelUsed,
durationMs: result.durationMs,
fallbackTriggered: result.fallbackTriggered,
remainingQuota: updatedLimit.remaining,
warnings: result.warnings,
});
} catch (error: any) {
console.error("[API Generate] Exception:", error);
const isSafety = isContentSafetyError(error);
// Record error or content safety refusal in admin logs
try {
await logGenerationEvent({
styleId: selectedStyle?.id || requestedStyleId || "unknown",
styleName: selectedStyle?.name || "Unknown Disguise",
provider: error.provider || "unknown",
model: error.model || "ai-pipeline",
durationMs: Date.now() - requestStartTime,
status: isSafety ? "refused" : "error",
errorType: isSafety ? "safety_refusal" : "server_error",
errorMessage: error.message || "An unexpected error occurred during generation.",
refusalReason: isSafety
? (error.message || "Content safety filter blocked output")
: undefined,
clientId,
});
} catch (logErr) {
console.error("[API Generate] Failed to log error event:", logErr);
}
return NextResponse.json(
{
success: false,
isSafetyRejection: isSafety,
error: isSafety
? "This photo was rejected by the content safety filter. Please snap or select a different photo."
: (error.message || "An unexpected error occurred during generation."),
},
{ status: isSafety ? 422 : 500 }
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(
req: NextRequest,
{ params }: { params: { filename: string } }
) {
try {
const rawFilename = params.filename;
// 1. Strict filename validation: alphanumeric + hyphens/underscores + safe image extensions only
const validFilenameRegex = /^[a-zA-Z0-9_-]+\.(jpg|jpeg|png|webp)$/i;
if (!rawFilename || !validFilenameRegex.test(rawFilename)) {
return new NextResponse("Bad Request: Invalid or disallowed image filename", { status: 400 });
}
const filename = path.basename(rawFilename);
const imagesDir = path.resolve(process.cwd(), "data", "images");
const filePath = path.resolve(imagesDir, filename);
// 2. Prevent path traversal outside data/images directory
if (!filePath.startsWith(imagesDir)) {
return new NextResponse("Access Denied", { status: 403 });
}
if (!fs.existsSync(filePath)) {
return new NextResponse("Image Not Found", { status: 404 });
}
const fileBuffer = fs.readFileSync(filePath);
const ext = path.extname(filename).toLowerCase();
let contentType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
else if (ext === ".webp") contentType = "image/webp";
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("[API Image Serve] Error reading file:", error);
return new NextResponse("Server Error", { status: 500 });
}
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { getExpectedToken, isAuthenticated } from "@/lib/admin-auth";
import {
getClientIp,
constantTimeCompare,
checkAuthRateLimit,
recordAuthFailure,
recordAuthSuccess,
} from "@/lib/security";
export async function POST(req: NextRequest) {
const ip = getClientIp(req);
// 1. Check brute-force lockout
const authLimit = checkAuthRateLimit(ip, 5, 15);
if (!authLimit.allowed) {
const waitMins = Math.ceil(authLimit.lockoutSeconds / 60);
console.warn(`[Portal Auth Security] IP ${ip} blocked due to too many failed login attempts.`);
return NextResponse.json(
{
success: false,
error: `Too many failed login attempts. IP temporarily blocked. Try again in ${waitMins} minute${waitMins > 1 ? "s" : ""}.`,
},
{ status: 429 }
);
}
try {
const body = await req.json().catch(() => ({}));
const password = body.password;
const expectedPassword = process.env.ADMIN_PASSWORD || "admin123";
// 2. Validate password using timing-safe comparison
if (!password || typeof password !== "string" || !constantTimeCompare(password, expectedPassword)) {
recordAuthFailure(ip, 5, 15);
const remaining = authLimit.remainingAttempts - 1;
return NextResponse.json(
{
success: false,
error: "Incorrect password",
remainingAttempts: Math.max(0, remaining),
},
{ status: 401 }
);
}
// 3. Clear failed attempts on successful login
recordAuthSuccess(ip);
const token = getExpectedToken();
const response = NextResponse.json({
success: true,
token,
message: "Authentication successful",
});
response.cookies.set({
name: "booth_portal_token",
value: token,
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: "lax",
});
return response;
} catch (error: any) {
console.error("[Portal Auth] Login error:", error);
return NextResponse.json(
{ success: false, error: "Internal authentication error" },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
const authed = isAuthenticated(req);
if (authed) {
return NextResponse.json({ authenticated: true });
}
return NextResponse.json({ authenticated: false }, { status: 401 });
}
export async function DELETE() {
const response = NextResponse.json({ success: true, message: "Logged out" });
response.cookies.set({
name: "booth_portal_token",
value: "",
httpOnly: true,
path: "/",
maxAge: 0,
sameSite: "lax",
});
return response;
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/lib/admin-auth";
import { getGenerationStats } from "@/lib/storage";
export async function GET(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json(
{ error: "Unauthorized. Please login." },
{ status: 401 }
);
}
try {
const { searchParams } = new URL(req.url);
const periodParam = searchParams.get("period") as any;
const rawFromDate = searchParams.get("from") || undefined;
const rawToDate = searchParams.get("to") || undefined;
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
const fromDate = rawFromDate && dateRegex.test(rawFromDate) ? rawFromDate : undefined;
const toDate = rawToDate && dateRegex.test(rawToDate) ? rawToDate : undefined;
const validPeriods = ["day", "week", "month", "all", "custom"];
const period = validPeriods.includes(periodParam) ? periodParam : "week";
const stats = getGenerationStats({
period,
fromDate,
toDate,
});
return NextResponse.json({
success: true,
stats,
});
} catch (error: any) {
console.error("[Portal Stats API] Error:", error);
return NextResponse.json(
{ success: false, error: "Failed to retrieve statistics" },
{ status: 500 }
);
}
}
+462
View File
@@ -0,0 +1,462 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Local Standalone Self-Hosted Fonts (Zero 3rd-party dependencies) */
@font-face {
font-family: 'CinzelTitle';
src: url('/fonts/Cinzel.ttf') format('truetype');
font-weight: 700 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DojoFun';
src: url('/fonts/ZCOOLKuaiLe.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
.font-cinzel {
font-family: 'CinzelTitle', 'Georgia', serif;
}
.font-dojo {
font-family: 'DojoFun', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
/* Fantasy Game UI & Spooky UI Elements */
.game-panel {
background: linear-gradient(180deg, #1f1438 0%, #120a22 100%);
border: 2px solid #5b358a;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
inset 0 -2px 0 rgba(0, 0, 0, 0.6),
0 8px 24px rgba(0, 0, 0, 0.7),
0 0 15px rgba(168, 85, 247, 0.2);
border-radius: 16px;
position: relative;
}
.game-panel-amber {
background: linear-gradient(180deg, #241432 0%, #150920 100%);
border: 2px solid #9a531e;
box-shadow:
inset 0 1px 0 rgba(251, 191, 36, 0.25),
inset 0 -2px 0 rgba(0, 0, 0, 0.7),
0 8px 24px rgba(0, 0, 0, 0.8),
0 0 20px rgba(249, 115, 22, 0.25);
border-radius: 18px;
position: relative;
}
.game-btn-gold {
background: linear-gradient(180deg, #ff9d00 0%, #ea580c 45%, #c2410c 100%);
border-top: 2px solid #fef08a;
border-left: 2px solid #f59e0b;
border-right: 2px solid #f59e0b;
border-bottom: 3px solid #7c2d12;
box-shadow:
0 6px 0 #431407,
0 10px 25px rgba(234, 88, 12, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
color: #ffffff;
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.8);
border-radius: 14px;
transition: all 0.15s ease;
}
.game-btn-gold:hover {
filter: brightness(1.1);
}
.game-btn-gold:active {
transform: translateY(4px);
box-shadow:
0 2px 0 #431407,
0 4px 10px rgba(234, 88, 12, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.game-btn-dark {
background: linear-gradient(180deg, #2d1a4d 0%, #1b0e32 100%);
border-top: 1.5px solid #a855f7;
border-left: 1.5px solid #7e22ce;
border-right: 1.5px solid #7e22ce;
border-bottom: 2.5px solid #3b0764;
box-shadow:
0 4px 0 #090314,
inset 0 1px 0 rgba(255, 255, 255, 0.2);
color: #f3e8ff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
border-radius: 14px;
transition: all 0.15s ease;
}
.game-btn-dark:active {
transform: translateY(2px);
box-shadow: 0 2px 0 #090314;
}
/* Ornate RPG Ribbon Banner Header */
.game-ribbon {
background: linear-gradient(180deg, #7c3aed 0%, #581c87 100%);
border: 1.5px solid #f59e0b;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.6),
0 0 12px rgba(245, 158, 11, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
position: relative;
}
:root {
--brand-primary: #ff6d00;
--brand-accent: #22c55e;
--brand-bg: #0f091f;
--brand-surface: #1e1136;
--brand-text: #ffffff;
--brand-muted: rgba(238, 230, 255, 0.75);
--sat: env(safe-area-inset-top, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
--sar: env(safe-area-inset-right, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
body {
margin: 0;
padding: 0;
background-color: var(--brand-bg);
color: var(--brand-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
height: 100vh;
height: 100dvh;
width: 100vw;
width: 100dvw;
overscroll-behavior: none;
touch-action: manipulation;
}
/* Kiosk and Responsive Container */
.kiosk-viewport {
width: 100%;
height: 100%;
height: 100dvh;
max-width: 540px; /* Optimal vertical kiosk aspect on desktop */
max-height: 960px;
position: relative;
overflow: hidden;
box-shadow: 0 25px 60px -10px rgba(0, 0, 0, 0.7), 0 0 40px rgba(168, 85, 247, 0.15);
display: flex;
flex-direction: column;
}
@media (max-width: 640px) {
.kiosk-viewport {
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
max-width: 100vw;
max-height: 100dvh;
border-radius: 0 !important;
border-width: 0 !important;
box-shadow: none !important;
}
}
/* Fullscreen Mode URL parameter reset (?mode=fullscreen) */
body:has([data-fullscreen="true"]) {
padding: 0 !important;
background-color: var(--brand-bg);
}
/* Subtle border vignette */
.bg-radial-vignette {
background: radial-gradient(circle at center, transparent 60%, rgba(10, 4, 20, 0.6) 100%);
}
/* Spooky Halloween Animations */
@keyframes auroraWave {
0%, 100% {
transform: translateX(-5%) skewY(-2deg) scaleY(0.9);
opacity: 0.35;
}
50% {
transform: translateX(5%) skewY(2deg) scaleY(1.15);
opacity: 0.55;
}
}
@keyframes auroraPulse {
0%, 100% {
transform: scale(0.95);
opacity: 0.25;
}
50% {
transform: scale(1.05);
opacity: 0.45;
}
}
@keyframes cloudDrift {
0% {
transform: translateX(-15px);
opacity: 0.4;
}
50% {
transform: translateX(20px);
opacity: 0.7;
}
100% {
transform: translateX(-15px);
opacity: 0.4;
}
}
@keyframes batFlutter {
0%, 100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
25% {
transform: translate(6px, -8px) rotate(6deg) scale(1.05);
}
50% {
transform: translate(-4px, -12px) rotate(-4deg) scale(0.95);
}
75% {
transform: translate(8px, -4px) rotate(4deg) scale(1.02);
}
}
@keyframes ghostFloat {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-12px) rotate(3deg);
}
}
@keyframes leafDrift {
0% {
transform: translateY(-20px) translateX(0) rotate(0deg);
opacity: 0;
}
15% {
opacity: 0.85;
}
50% {
transform: translateY(400px) translateX(30px) rotate(180deg);
opacity: 0.8;
}
85% {
opacity: 0.7;
}
100% {
transform: translateY(960px) translateX(-20px) rotate(360deg);
opacity: 0;
}
}
.animate-aurora-wave {
animation: auroraWave 12s ease-in-out infinite alternate;
}
.animate-aurora-pulse {
animation: auroraPulse 8s ease-in-out infinite alternate;
}
.animate-cloud-drift {
animation: cloudDrift 18s ease-in-out infinite;
}
.bat-1 {
animation: batFlutter 6s ease-in-out infinite;
}
.bat-2 {
animation: batFlutter 8s ease-in-out 1.5s infinite;
}
.bat-3 {
animation: batFlutter 7s ease-in-out 3s infinite;
}
.spooky-ghost {
animation: ghostFloat 4.5s ease-in-out infinite;
}
.leaf-1 {
animation: leafDrift 14s linear infinite;
}
.leaf-2 {
animation: leafDrift 18s linear 4s infinite;
}
.leaf-3 {
animation: leafDrift 16s linear 8s infinite;
}
/* Accessible Reduced Motion Settings */
@media (prefers-reduced-motion: reduce) {
.animate-aurora-wave,
.animate-aurora-pulse,
.animate-cloud-drift,
.bat-1,
.bat-2,
.bat-3,
.spooky-ghost,
.leaf-1,
.leaf-2,
.leaf-3 {
animation: none !important;
transform: none !important;
}
}
/* Pulsing loader dots */
@keyframes dotPulse {
0%, 100% {
transform: scale(0.8);
opacity: 0.4;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
.animate-dot-1 {
animation: dotPulse 1.4s ease-in-out infinite;
}
.animate-dot-2 {
animation: dotPulse 1.4s ease-in-out 0.2s infinite;
}
.animate-dot-3 {
animation: dotPulse 1.4s ease-in-out 0.4s infinite;
}
/* Cartoon UI Utilities (Tactile, chunky, playful) */
.btn-cartoon-orange {
background: linear-gradient(180deg, #ff9100 0%, #ff6d00 50%, #ea580c 100%);
border: 3px solid #1a0826;
box-shadow: 0 6px 0 #9a3412, 0 10px 15px rgba(0, 0, 0, 0.4);
transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn-cartoon-orange:hover {
filter: brightness(1.08);
transform: translateY(-2px);
box-shadow: 0 8px 0 #9a3412, 0 14px 20px rgba(0, 0, 0, 0.5);
}
.btn-cartoon-orange:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #9a3412, 0 4px 6px rgba(0, 0, 0, 0.4);
}
.btn-cartoon-purple {
background: linear-gradient(180deg, #a855f7 0%, #7e22ce 50%, #581c87 100%);
border: 3px solid #1a0826;
box-shadow: 0 6px 0 #3b0764, 0 10px 15px rgba(0, 0, 0, 0.4);
transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn-cartoon-purple:hover {
filter: brightness(1.08);
transform: translateY(-2px);
box-shadow: 0 8px 0 #3b0764, 0 14px 20px rgba(0, 0, 0, 0.5);
}
.btn-cartoon-purple:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #3b0764, 0 4px 6px rgba(0, 0, 0, 0.4);
}
.btn-cartoon-instagram {
background: linear-gradient(135deg, #833ab4 0%, #c13584 30%, #e1306c 60%, #fd1d1d 80%, #f77737 100%);
border: 3px solid #1a0826;
box-shadow: 0 6px 0 #4c0519, 0 10px 15px rgba(0, 0, 0, 0.4);
transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn-cartoon-instagram:hover {
filter: brightness(1.08);
transform: translateY(-2px);
box-shadow: 0 8px 0 #4c0519, 0 14px 20px rgba(0, 0, 0, 0.5);
}
.btn-cartoon-instagram:active {
transform: translateY(4px);
box-shadow: 0 2px 0 #4c0519, 0 4px 6px rgba(0, 0, 0, 0.4);
}
.btn-cartoon-dark {
background: linear-gradient(180deg, #2e1065 0%, #1e0e38 100%);
border: 3px solid #090314;
box-shadow: 0 5px 0 #090314, 0 8px 12px rgba(0, 0, 0, 0.4);
transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.btn-cartoon-dark:hover {
transform: translateY(-2px);
box-shadow: 0 7px 0 #090314, 0 12px 16px rgba(0, 0, 0, 0.5);
}
.btn-cartoon-dark:active {
transform: translateY(3px);
box-shadow: 0 2px 0 #090314, 0 4px 6px rgba(0, 0, 0, 0.4);
}
.card-cartoon {
border: 3px solid #1a0826;
box-shadow: 0 6px 0 rgba(10, 4, 20, 0.8), 0 12px 20px rgba(0, 0, 0, 0.5);
}
.card-cartoon-selected {
border: 3px solid #ff9100;
box-shadow: 0 6px 0 #9a3412, 0 0 24px rgba(255, 145, 0, 0.5);
}
@keyframes cartoonWobble {
0%, 100% {
transform: rotate(0deg) scale(1);
}
25% {
transform: rotate(-3deg) scale(1.03);
}
75% {
transform: rotate(3deg) scale(1.03);
}
}
.animate-cartoon-wobble {
animation: cartoonWobble 3s ease-in-out infinite;
}
/* Media Print Optimization */
@media print {
body {
background: #ffffff !important;
color: #000000 !important;
}
.no-print {
display: none !important;
}
.print-only-container {
display: flex !important;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
}
.print-photo {
max-width: 100%;
max-height: 100%;
object-fit: contain;
page-break-inside: avoid;
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Spooky Hand · 万圣空手道馆 | Shanghai Karate Club",
description:
"上海健志空手道馆万圣节专属照相馆 (Shanghai Karate Club Halloween Photobooth) - 凝聚万圣气合,定格武道英姿!",
icons: {
icon: "/assets/brand/shanghai_karate_logo.png",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#0b0714",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="antialiased flex items-center justify-center bg-[#090412] sm:p-4 selection:bg-orange-500 selection:text-white">
{children}
</body>
</html>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { AlertTriangle, Camera } from "lucide-react";
import { KioskFrame } from "@/components/KioskFrame";
import { StandbyScreen } from "@/components/screens/StandbyScreen";
import { CameraScreen } from "@/components/screens/CameraScreen";
import { ConfirmScreen } from "@/components/screens/ConfirmScreen";
import { StyleScreen } from "@/components/screens/StyleScreen";
import { LoadingScreen } from "@/components/screens/LoadingScreen";
import { ResultScreen } from "@/components/screens/ResultScreen";
import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen";
import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth";
import { I18nProvider, useI18n } from "@/lib/i18n";
function PhotoBoothApp() {
const [config, setConfig] = useState<BoothConfig | null>(null);
const [styles, setStyles] = useState<BoothStyle[]>([]);
const [screen, setScreen] = useState<ScreenState>("standby");
const [capturedPhoto, setCapturedPhoto] = useState<string | null>(null);
const [selectedStyle, setSelectedStyle] = useState<BoothStyle | null>(null);
const [resultImage, setResultImage] = useState<string | null>(null);
const [providerInfo, setProviderInfo] = useState<any>(null);
const [rateLimitResetMinutes, setRateLimitResetMinutes] = useState<number>(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
const [clientId, setClientId] = useState<string>("");
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const { t } = useI18n();
useEffect(() => {
let storedId = localStorage.getItem("booth_client_id");
if (!storedId) {
storedId = "dev_" + Math.random().toString(36).substring(2, 12);
localStorage.setItem("booth_client_id", storedId);
}
setClientId(storedId);
fetch("/api/config")
.then((res) => res.json())
.then((data) => {
if (data.styles) {
setStyles(data.styles);
}
setConfig(data);
// URL parameter support for direct previewing, e.g. /?preview=result
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const previewScreen = params.get("preview") as ScreenState | null;
if (previewScreen === "result") {
const previewImg = params.get("image") || "/assets/styles/jack_o_lantern_sensei.jpg";
setResultImage(previewImg);
setScreen("result");
}
}
})
.catch((err) => console.error("Failed to load booth config:", err));
}, []);
const resetIdleTimer = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
if (!config?.kiosk?.enableAutoReset || screen === "standby") {
return;
}
const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000;
idleTimerRef.current = setTimeout(() => {
console.log("[Kiosk] Idle timeout reached. Resetting to standby...");
setScreen("standby");
setCapturedPhoto(null);
setSelectedStyle(null);
setResultImage(null);
setErrorMessage(null);
setSafetyWarning(null);
}, timeoutMs);
}, [config, screen]);
useEffect(() => {
const handleActivity = () => resetIdleTimer();
window.addEventListener("pointerdown", handleActivity);
window.addEventListener("keydown", handleActivity);
resetIdleTimer();
return () => {
window.removeEventListener("pointerdown", handleActivity);
window.removeEventListener("keydown", handleActivity);
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
};
}, [resetIdleTimer]);
const handleStart = () => {
setErrorMessage(null);
setSafetyWarning(null);
setScreen("camera");
};
const handlePhotoCaptured = (photoBase64: string) => {
setCapturedPhoto(photoBase64);
setScreen("confirm");
};
const handleRetake = () => {
setCapturedPhoto(null);
setScreen("camera");
};
const handleConfirmPhoto = () => {
setScreen("style");
};
const handleStyleSelected = async (style: BoothStyle) => {
setSelectedStyle(style);
setScreen("processing");
setErrorMessage(null);
setSafetyWarning(null);
try {
const response = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-client-id": clientId,
},
body: JSON.stringify({
imageBase64: capturedPhoto,
styleId: style.id,
}),
});
const data = await response.json();
if (response.status === 429) {
setRateLimitResetMinutes(data.resetInMinutes || 60);
setScreen("rate_limited");
return;
}
if (!response.ok) {
if (data.isSafetyViolation) {
setSafetyWarning(data.error);
} else {
setErrorMessage(data.error || "Generation failed. Please try again.");
}
setScreen("style");
return;
}
setResultImage(data.imageUrl);
setProviderInfo({
provider: data.provider,
model: data.model,
durationMs: data.durationMs,
fallbackTriggered: data.fallbackTriggered,
});
setScreen("result");
} catch (err: any) {
console.error("API call error:", err);
setErrorMessage(
t({
en: "Network error. Please check your internet connection and try again.",
zh: "网络连接异常,请检查网络后重试。"
})
);
setScreen("style");
}
};
const handleStartOver = () => {
setCapturedPhoto(null);
setSelectedStyle(null);
setResultImage(null);
setErrorMessage(null);
setSafetyWarning(null);
setScreen("standby");
};
if (!config) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#090314] text-white">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 rounded-full border-4 border-amber-500 border-t-transparent animate-spin" />
<p className="text-sm font-bold text-amber-300">🥋 加载万圣道馆中...</p>
</div>
</div>
);
}
return (
<KioskFrame
config={config}
showHeaderLogo={screen === "standby"}
showLanguageToggle={screen === "standby"}
>
{safetyWarning && (
<div className="absolute top-2 left-4 right-4 z-50 p-3 bg-red-600/90 text-white rounded-2xl border-2 border-black shadow-[0_4px_0_#090314] flex items-center space-x-2 text-xs font-bold animate-bounce">
<AlertTriangle size={18} className="shrink-0" />
<span>{safetyWarning}</span>
</div>
)}
{errorMessage && (
<div className="absolute top-2 left-4 right-4 z-50 p-3 bg-amber-600/90 text-white rounded-2xl border-2 border-black shadow-[0_4px_0_#090314] flex items-center space-x-2 text-xs font-bold animate-pulse">
<AlertTriangle size={18} className="shrink-0" />
<span>{errorMessage}</span>
</div>
)}
{screen === "standby" && (
<StandbyScreen config={config} onStart={handleStart} />
)}
{screen === "camera" && (
<CameraScreen
config={config}
onPhotoCaptured={handlePhotoCaptured}
onBack={handleStartOver}
/>
)}
{screen === "confirm" && capturedPhoto && (
<ConfirmScreen
config={config}
photoUrl={capturedPhoto}
onConfirm={handleConfirmPhoto}
onRetake={handleRetake}
/>
)}
{screen === "style" && (
<StyleScreen
config={config}
styles={styles}
onStyleSelected={handleStyleSelected}
onBack={() => setScreen("confirm")}
/>
)}
{screen === "processing" && <LoadingScreen config={config} />}
{screen === "result" && resultImage && (
<ResultScreen
config={config}
resultImageUrl={resultImage}
providerInfo={providerInfo}
onStartOver={handleStartOver}
/>
)}
{screen === "rate_limited" && (
<RateLimitedScreen
config={config}
resetMinutes={rateLimitResetMinutes}
onReset={handleStartOver}
/>
)}
</KioskFrame>
);
}
export default function PhotoBoothPage() {
return (
<I18nProvider>
<PhotoBoothApp />
</I18nProvider>
);
}
+744
View File
@@ -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>
);
}