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,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,
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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