feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# Google Gemini API Key (Primary Provider)
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# OpenRouter API Key (Backup Provider)
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
|
||||
# Selected model to use for generation: 'nanobanana 2', 'lite', or 'pro'
|
||||
GENERATION_MODEL=nanobanana 2
|
||||
|
||||
# Optional: Port override
|
||||
PORT=3000
|
||||
|
||||
# Admin Panel Password
|
||||
ADMIN_PASSWORD=admin123
|
||||
@@ -0,0 +1,46 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Next.js build output
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Environment variables & secrets
|
||||
.env*.local
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# OS / Editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Uploaded / Generated user images & data backups
|
||||
/data/images/
|
||||
/data/records.json
|
||||
/data/*.json
|
||||
/data/*.bak
|
||||
/data/*.tmp
|
||||
@@ -0,0 +1,221 @@
|
||||
# YEG SpookyBooth 🎃
|
||||
|
||||
> **Edmonton Halloween Magic Photo Experience**
|
||||
> An interactive, responsive, and kiosk-ready photo experience celebrating Canadian autumn folklore and Edmonton's river valley magic. Built with **Next.js**, **React**, **TypeScript**, and **Tailwind CSS**.
|
||||
|
||||
Users step into the enchanted portal, capture a photo, pick an iconic Canadian/Edmonton Halloween disguise, and receive a transformed 3D cartoon character portrait.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- 🍁 **Edmonton & Canadian Halloween Atmosphere**:
|
||||
- Eerie shimmering **Northern Lights (Aurora Borealis)**.
|
||||
- Silhouettes of the iconic **High Level Bridge**, **Walterdale Arch**, glowing **Muttart Conservatory Pyramids**, and river valley spruce trees.
|
||||
- Radiant harvest moon, drifting autumn maple leaves, friendly bats, and a cute hovering ghost.
|
||||
- 🎨 **Stylized 3D Cartoon Character Disguises**:
|
||||
- 🌟 **Vibe Matcher**: Keep your exact face, outfit, hair & makeup completely unchanged, but replace the background with a funny, exaggerated scene that matches your vibe!
|
||||
- 🎸 **Classic Mullet**: Keep your photo portrait intact while rocking an iconic 80s mullet hairstyle.
|
||||
- 🏛️ **Muttart Pyramid Witch**: Whimsical young witch brewing emerald potions inside the glass conservatory pyramid.
|
||||
- 🦇 **High Level Vampire**: Chibi vampire in a winter velvet coat hovering above the foggy bridge.
|
||||
- 🧙♂️ **100% Magical Lore**: Zero robotic AI technical jargon. The entire user experience is written as enchanting spooky spellcraft.
|
||||
- 🕹️ **Tactile Cartoon Arcade UI**:
|
||||
- Chunky 3D buttons (`.btn-cartoon-orange`, `.btn-cartoon-purple`, `.btn-cartoon-dark`) with satisfying downward click physics.
|
||||
- Bold comic-book outlines and playful cartoon wobbles.
|
||||
- 🧭 **Complete Navigation**:
|
||||
- **`← Back`** buttons across all intermediate screens (Camera, Confirm, Style Selection, and Loading).
|
||||
- Dedicated **`Finish 🏁`** button on the final result screen to reset the booth.
|
||||
- Top navigation bars use a strictly non-overlapping 3-column layout that never covers or blocks buttons.
|
||||
- 📲 **QR Code & Print Ready**:
|
||||
- On-screen QR code allows visitors to scan and instantly save their portrait to their smartphone.
|
||||
- Integrated 1-click photo printing formatted for 4x6 print media via `@media print`.
|
||||
- 🛡️ **Intelligent Content Safety & Zero-Lockout Uptime**:
|
||||
- Automatically identifies and handles image safety filter rejections (e.g. `PROHIBITED_CONTENT`, Safe-for-Work checks).
|
||||
- Safety rejections immediately return the user to the camera/photo capture screen with an interactive Spooky Safety Warning modal to snap or choose another photo.
|
||||
- No API cooldown lockouts: input rejections never penalize or disable the booth for subsequent guests.
|
||||
- ⏱️ **Kiosk Inactivity Auto-Reset**: Automatically resets to the standby welcome screen after $N$ seconds of inactivity.
|
||||
- 🏷️ **Sokaris Studio Attribution**: Built-in footer attribution and copyright link to [sokaris.com](https://sokaris.com).
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Display Modes
|
||||
|
||||
### 1. Standard Mode (Default)
|
||||
By default, the booth renders in a centered, vertical 9:16 kiosk viewport (`max-width: 540px`, `max-height: 960px`) on desktop and automatically expands on mobile screens. Ideal for testing, desktop browsing, and tablets:
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
### 2. Fullscreen Kiosk Mode (`?mode=fullscreen`)
|
||||
For physical event touchscreens, vertical digital signage, or dedicated kiosk hardware, add `?mode=fullscreen` to the URL:
|
||||
```
|
||||
http://localhost:3000/?mode=fullscreen
|
||||
```
|
||||
**What this does:**
|
||||
1. Expands the viewport to **100% screen width and 100% screen height** (`100vw` x `100vh`).
|
||||
2. Removes all desktop borders, rounded corners, and margins.
|
||||
3. Automatically triggers native browser fullscreen (`document.documentElement.requestFullscreen()`) upon the user's first tap.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Prerequisites
|
||||
- **Node.js** 18.17+ or 20+
|
||||
- **npm** or **yarn**
|
||||
|
||||
### 2. Install Dependencies
|
||||
```cmd
|
||||
cmd /c npm install
|
||||
```
|
||||
|
||||
### 3. Configure Environment Variables
|
||||
Copy `.env.example` to `.env.local` and add your API credentials:
|
||||
```env
|
||||
# Google Gemini API Key (Primary Provider)
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# OpenRouter API Key (Automatic Backup Provider)
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
|
||||
# Model Selection Override (Optional: "nanobanana 2", "nanobanana", "pro", "lite")
|
||||
GENERATION_MODEL=nanobanana 2
|
||||
```
|
||||
|
||||
*(If no keys are provided, the app will run gracefully in demo fallback mode using pre-rendered sample portraits).*
|
||||
|
||||
### 4. Run Development Server
|
||||
```cmd
|
||||
cmd /c npm run dev
|
||||
```
|
||||
Open [http://localhost:3000](http://localhost:3000) (or [http://localhost:3000/?mode=fullscreen](http://localhost:3000/?mode=fullscreen)) in your browser.
|
||||
|
||||
### 5. Build for Production
|
||||
```cmd
|
||||
cmd /c npm run build
|
||||
cmd /c npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration Guide
|
||||
|
||||
### 1. General Settings (`config/booth.config.json`)
|
||||
|
||||
All booth behaviors and visuals can be customized without editing code:
|
||||
|
||||
```json
|
||||
{
|
||||
"appName": "YEG SpookyBooth",
|
||||
"kioskMode": true,
|
||||
"theme": {
|
||||
"backgroundType": "color",
|
||||
"backgroundColor": "#0f091f",
|
||||
"primaryColor": "#ff6d00",
|
||||
"accentColor": "#22c55e",
|
||||
"surfaceColor": "#1e1136",
|
||||
"textColor": "#ffffff",
|
||||
"logoUrl": "/assets/brand/spooky_logo.svg",
|
||||
"logoWidth": 260
|
||||
},
|
||||
"camera": {
|
||||
"defaultFacingMode": "user",
|
||||
"aspectRatio": "9:16",
|
||||
"countdownSeconds": 3,
|
||||
"allowUpload": true,
|
||||
"maxUploadSizeMb": 10
|
||||
},
|
||||
"features": {
|
||||
"showQrCode": true,
|
||||
"showDownloadButton": true,
|
||||
"showPrintButton": true,
|
||||
"showRetakeButton": true
|
||||
},
|
||||
"limits": {
|
||||
"maxGenerationsPerDevice": 0,
|
||||
"cooldownMinutes": 60
|
||||
},
|
||||
"kiosk": {
|
||||
"enableAutoReset": true,
|
||||
"idleTimeoutSeconds": 90
|
||||
},
|
||||
"labels": {
|
||||
"welcomeTitle": "YEG SPOOKY BOOTH",
|
||||
"welcomeSubtitle": "Edmonton Halloween Magic Portal",
|
||||
"welcomeCta": "Tap To Enter If You Dare, Eh! 🎃",
|
||||
"cameraInstruction": "Strike your most spooktacular pose!",
|
||||
"cameraSnap": "Snap Spooky Photo",
|
||||
"cameraUpload": "Upload Photo",
|
||||
"confirmTitle": "Review Your Spooky Shot",
|
||||
"styleSelectionTitle": "Pick Your Halloween Disguise",
|
||||
"styleSelectionSubtitle": "Choose an iconic local legend to transform into",
|
||||
"processingTitle": "Brewing Your Transformation Spell...",
|
||||
"processingSubtitle": "Mixing river valley mist, northern starlight & potion sparks...",
|
||||
"resultTitle": "Your Spooktacular Portrait! 🎃",
|
||||
"scanQrLabel": "Scan to take your treat home",
|
||||
"downloadLabel": "Save Portrait",
|
||||
"printLabel": "Print Photo",
|
||||
"startOverLabel": "Haunt Again"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Styles & Prompts (`config/styles.json`)
|
||||
|
||||
To add, remove, or modify character disguise presets, edit `config/styles.json`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "yeg_hockey_phantom",
|
||||
"name": "YEG Hockey Phantom",
|
||||
"description": "Friendly vintage hockey ghost on river ice under the glowing Northern Lights",
|
||||
"thumbnail": "/assets/styles/hockey_ghost.jpg",
|
||||
"prompt": "Vibrant 3D cartoon animated character portrait based on the person in the user's photo... Bubbly cartoon animation style, Hotel Transylvania and Pixar aesthetics...",
|
||||
"negativePrompt": "scary, bloody, gore, grotesque, deformed hands, bad anatomy, realistic human skin pores, text watermark"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── config/route.ts # API endpoint serving booth config & styles
|
||||
│ │ └── generate/route.ts # AI image transformation endpoint with failover
|
||||
│ ├── globals.css # Design tokens, cartoon button physics & animations
|
||||
│ ├── layout.tsx # Root HTML layout & meta tags
|
||||
│ └── page.tsx # Main state machine routing screens
|
||||
├── components/
|
||||
│ ├── KioskFrame.tsx # Kiosk frame, ?mode=fullscreen handler & footer
|
||||
│ ├── SpookyAtmosphere.tsx # Animated Edmonton skyline, aurora & falling leaves
|
||||
│ └── screens/
|
||||
│ ├── StandbyScreen.tsx # Welcome screen with bouncy hero pumpkin
|
||||
│ ├── CameraScreen.tsx # Camera capture, countdown, and face guide
|
||||
│ ├── ConfirmScreen.tsx # Photo review with retake / confirm
|
||||
│ ├── StyleScreen.tsx # 2x2 grid of cartoon character cards
|
||||
│ ├── LoadingScreen.tsx # Cauldron animation with magical brewing steps
|
||||
│ ├── ResultScreen.tsx # Confetti celebration, download, QR code & Finish
|
||||
│ └── RateLimitedScreen.tsx# Rate-limit cooldown screen
|
||||
├── config/
|
||||
│ ├── booth.config.json # Main configuration file
|
||||
│ └── styles.json # Character styles and prompt presets
|
||||
├── lib/
|
||||
│ ├── ai/ # Gemini & OpenRouter provider orchestration
|
||||
│ ├── config.ts # Server-side configuration loader
|
||||
│ └── ratelimit.ts # Device generation limits & cooldown tracker
|
||||
└── public/
|
||||
└── assets/
|
||||
├── brand/ # Spooky logo SVG & Sokaris Studio logos
|
||||
└── styles/ # Cartoon character artwork images
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Credits & License
|
||||
|
||||
- Designed and developed for **YEG Halloween Photo Experiences**.
|
||||
- Attribution & branding by **Sokaris Studio** ([sokaris.com](https://sokaris.com)).
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { SpookyAtmosphere } from "@/components/SpookyAtmosphere";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
interface KioskFrameProps {
|
||||
config: BoothConfig;
|
||||
children: React.ReactNode;
|
||||
showHeaderLogo?: boolean;
|
||||
showLanguageToggle?: boolean;
|
||||
}
|
||||
|
||||
export const KioskFrame: React.FC<KioskFrameProps> = ({
|
||||
config,
|
||||
children,
|
||||
showHeaderLogo = true,
|
||||
showLanguageToggle = true,
|
||||
}) => {
|
||||
const [isModeFullscreen, setIsModeFullscreen] = useState(false);
|
||||
const { language, toggleLanguage, isWeChat } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isFull = params.get("mode") === "fullscreen";
|
||||
setIsModeFullscreen(isFull);
|
||||
|
||||
if (isFull) {
|
||||
const triggerFullscreen = () => {
|
||||
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
console.log("[Kiosk] Fullscreen request prevented:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", triggerFullscreen, { once: true });
|
||||
return () => window.removeEventListener("pointerdown", triggerFullscreen);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const theme = config.theme;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-fullscreen={isModeFullscreen}
|
||||
className={`relative flex flex-col overflow-hidden select-none transition-all duration-300 ${
|
||||
isModeFullscreen
|
||||
? "w-screen h-screen max-w-none max-h-none rounded-none border-0 shadow-none"
|
||||
: "kiosk-viewport rounded-none sm:rounded-3xl border-4 border-black shadow-[0_12px_0_#090314,0_0_40px_rgba(255,109,0,0.25)]"
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
"--brand-primary": theme.primaryColor,
|
||||
"--brand-accent": theme.accentColor,
|
||||
"--brand-bg": theme.backgroundColor,
|
||||
"--brand-surface": theme.surfaceColor,
|
||||
"--brand-text": theme.textColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Dynamic Martial Arts & Shanghai Halloween Atmosphere */}
|
||||
<SpookyAtmosphere />
|
||||
|
||||
{/* Floating Language Toggle (Top Right) */}
|
||||
{showLanguageToggle && (
|
||||
<div className="absolute top-[max(0.75rem,env(safe-area-inset-top))] right-4 z-30">
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-[#1b0f2e]/90 hover:bg-[#2b184a] text-amber-300 border-2 border-black shadow-[0_3px_0_#090314] text-xs font-black tracking-wider transition active:scale-95"
|
||||
title={language === "zh" ? "Switch to English" : "切换为中文"}
|
||||
>
|
||||
<Globe size={13} className="text-amber-400" />
|
||||
<span>{language === "zh" ? "EN" : "中"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand Header Logo - 20% larger */}
|
||||
{showHeaderLogo && (
|
||||
<header className="no-print pt-[max(0.5rem,env(safe-area-inset-top))] sm:pt-3 pb-0.5 px-4 flex justify-center items-center z-20">
|
||||
<div className="relative flex items-center justify-center">
|
||||
{theme.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={theme.logoUrl}
|
||||
alt={config.appName}
|
||||
className="w-56 sm:w-72 md:w-84 h-auto max-h-16 sm:max-h-20 object-contain drop-shadow-[0_4px_14px_rgba(0,0,0,0.85)] filter"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg sm:text-xl font-black tracking-wider text-white uppercase drop-shadow-[0_3px_0_#090314]">
|
||||
{config.appName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Screen Content Layer */}
|
||||
<main className="flex-1 relative flex flex-col overflow-hidden z-10 w-full pl-[max(0px,env(safe-area-inset-left))] pr-[max(0px,env(safe-area-inset-right))]">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Dojo Brand Footer - clean single line, smaller font, no emoji */}
|
||||
<footer className="no-print pt-0.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] px-2 flex items-center justify-center z-20 select-none">
|
||||
<a
|
||||
href="https://www.shanghaikarate.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[8.5px] sm:text-[9.5px] text-white/50 hover:text-amber-300 transition-colors whitespace-nowrap overflow-hidden text-ellipsis tracking-wide"
|
||||
title="Shanghai Karate Club (shanghaikarate.com)"
|
||||
>
|
||||
<span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export type MascotPose = "bow" | "punch" | "kick" | "zen";
|
||||
|
||||
interface MascotGuideProps {
|
||||
pose?: MascotPose;
|
||||
speechText?: {
|
||||
en: string;
|
||||
zh: string;
|
||||
};
|
||||
speechPosition?: "left" | "right" | "top";
|
||||
className?: string;
|
||||
speechClassName?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
const POSE_IMAGES: Record<MascotPose, string> = {
|
||||
bow: "/assets/theme/mascot_bow.png",
|
||||
punch: "/assets/theme/mascot_punch.png",
|
||||
kick: "/assets/theme/mascot_kick.png",
|
||||
zen: "/assets/theme/mascot_zen.png",
|
||||
};
|
||||
|
||||
export const MascotGuide: React.FC<MascotGuideProps> = ({
|
||||
pose = "bow",
|
||||
speechText,
|
||||
speechPosition = "right",
|
||||
className = "",
|
||||
speechClassName = "",
|
||||
size = "md",
|
||||
animate = true,
|
||||
}) => {
|
||||
const { t, language } = useI18n();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-28 h-28 sm:w-36 sm:h-36",
|
||||
md: "w-40 h-40 sm:w-48 sm:h-48",
|
||||
lg: "w-48 h-48 sm:w-56 sm:h-56",
|
||||
xl: "w-56 h-56 sm:w-68 sm:h-68",
|
||||
"2xl": "w-64 h-64 sm:w-80 sm:h-80",
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center select-none pointer-events-none ${
|
||||
speechPosition === "left"
|
||||
? "flex-row-reverse"
|
||||
: speechPosition === "top"
|
||||
? "flex-col-reverse items-center"
|
||||
: "flex-row"
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Mascot Cutout Character */}
|
||||
<div
|
||||
className={`relative shrink-0 ${
|
||||
animate ? "animate-cartoon-wobble" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Soft magical glow backdrop */}
|
||||
<div className="absolute inset-0 rounded-full bg-amber-400/25 blur-2xl transform scale-125" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={POSE_IMAGES[pose]}
|
||||
alt={`Sensei Spook - ${pose}`}
|
||||
className={`${sizeClasses} object-contain filter drop-shadow-[0_12px_22px_rgba(0,0,0,0.85)] relative z-10`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comic Speech Bubble */}
|
||||
{speechText && (
|
||||
<div
|
||||
className={`relative z-20 pointer-events-auto ${
|
||||
speechPosition === "top"
|
||||
? "mb-1"
|
||||
: speechPosition === "left"
|
||||
? "-mr-1 sm:mr-0"
|
||||
: "-ml-3 sm:-ml-2"
|
||||
} ${speechClassName}`}
|
||||
>
|
||||
<div className="relative px-3.5 py-2.5 rounded-2xl bg-[#1b0f2e]/95 border-2 border-amber-400/90 shadow-[0_4px_18px_rgba(0,0,0,0.85)] max-w-[240px] sm:max-w-[290px] text-left backdrop-blur-sm">
|
||||
{/* Bubble arrow tail pointing towards the ghost */}
|
||||
{speechPosition === "right" && (
|
||||
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-0 h-0 border-y-[7px] border-y-transparent border-r-[8px] border-r-amber-400 after:content-[''] after:absolute after:-top-[6px] after:left-[2px] after:border-y-[6px] after:border-y-transparent after:border-r-[7px] after:border-r-[#1b0f2e]" />
|
||||
)}
|
||||
{speechPosition === "left" && (
|
||||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 w-0 h-0 border-y-[7px] border-y-transparent border-l-[8px] border-l-amber-400 after:content-[''] after:absolute after:-top-[6px] after:right-[2px] after:border-y-[6px] after:border-y-transparent after:border-l-[7px] after:border-l-[#1b0f2e]" />
|
||||
)}
|
||||
{speechPosition === "top" && (
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-0 h-0 border-x-[7px] border-x-transparent border-t-[8px] border-t-amber-400 after:content-[''] after:absolute after:-left-[6px] after:-top-[8px] after:border-x-[6px] after:border-x-transparent after:border-t-[7px] after:border-t-[#1b0f2e]" />
|
||||
)}
|
||||
|
||||
<p
|
||||
className={`text-amber-100 font-extrabold text-[12px] sm:text-[14px] leading-snug drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${
|
||||
language === "en" ? "font-cinzel" : "font-dojo"
|
||||
}`}
|
||||
>
|
||||
{t(speechText)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface SpookyAtmosphereProps {
|
||||
className?: string;
|
||||
variant?: "kiosk" | "fullscreen";
|
||||
}
|
||||
|
||||
export const SpookyAtmosphere: React.FC<SpookyAtmosphereProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 overflow-hidden pointer-events-none select-none z-0 ${className}`}
|
||||
>
|
||||
{/* 1. Shanghai Dojo Courtyard Night Background Artwork */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/assets/theme/shanghai_dojo_night_bg.jpg"
|
||||
alt="Shanghai Dojo Night Background"
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105"
|
||||
/>
|
||||
|
||||
{/* 2. Cinematic Vignette & Deep Twilight Tint Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#06020c]/85 via-[#0e0618]/50 to-[#07020d]/90 mix-blend-multiply" />
|
||||
|
||||
{/* 3. Radial Torchlight / Lantern Glow from Bottom Corners */}
|
||||
<div className="absolute -bottom-10 -left-10 w-72 h-72 rounded-full bg-orange-600/25 blur-3xl" />
|
||||
<div className="absolute -bottom-10 -right-10 w-72 h-72 rounded-full bg-amber-500/20 blur-3xl" />
|
||||
|
||||
{/* 4. Top Header Moon Radiance */}
|
||||
<div className="absolute -top-16 -right-16 w-80 h-80 rounded-full bg-amber-300/15 blur-3xl" />
|
||||
|
||||
{/* 5. Floating Halloween Sparks & Chi Embers */}
|
||||
<div className="absolute top-[35%] left-[10%] w-2 h-2 rounded-full bg-amber-400 opacity-80 animate-ping" />
|
||||
<div className="absolute top-[22%] right-[18%] w-1.5 h-1.5 rounded-full bg-cyan-300 opacity-70 animate-pulse" />
|
||||
<div className="absolute top-[62%] right-[12%] w-2.5 h-2.5 rounded-full bg-orange-500 opacity-60 animate-bounce" />
|
||||
<div className="absolute top-[75%] left-[20%] w-1.5 h-1.5 rounded-full bg-amber-200 opacity-50 animate-ping" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Camera, SwitchCamera, Upload, AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface CameraScreenProps {
|
||||
config: BoothConfig;
|
||||
onPhotoCaptured: (imageBase64: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const CameraScreen: React.FC<CameraScreenProps> = ({
|
||||
config,
|
||||
onPhotoCaptured,
|
||||
onBack,
|
||||
}) => {
|
||||
const { t, isWeChat } = useI18n();
|
||||
const [facingMode, setFacingMode] = useState<"user" | "environment">(
|
||||
config.camera.defaultFacingMode || "user"
|
||||
);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [isFlashing, setIsFlashing] = useState(false);
|
||||
const [hasMultipleCameras, setHasMultipleCameras] = useState<boolean>(false);
|
||||
const [isSwitchingCamera, setIsSwitchingCamera] = useState<boolean>(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (e) {
|
||||
console.warn("Error stopping track:", e);
|
||||
}
|
||||
});
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setStream(null);
|
||||
}, []);
|
||||
|
||||
const checkCameraDevices = useCallback(async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = devices.filter((device) => device.kind === "videoinput");
|
||||
setHasMultipleCameras(videoInputs.length > 1);
|
||||
} catch (err) {
|
||||
console.warn("Could not enumerate camera devices:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== "undefined" && navigator.mediaDevices?.addEventListener) {
|
||||
const handleDeviceChange = () => {
|
||||
checkCameraDevices();
|
||||
};
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||
};
|
||||
}
|
||||
}, [checkCameraDevices]);
|
||||
|
||||
const startCamera = useCallback(async (targetFacingMode: "user" | "environment") => {
|
||||
try {
|
||||
setCameraError(null);
|
||||
stopStream();
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.isSecureContext &&
|
||||
window.location.hostname !== "localhost" &&
|
||||
window.location.hostname !== "127.0.0.1"
|
||||
) {
|
||||
setCameraError(
|
||||
t({
|
||||
en: "Camera access requires HTTPS or localhost. Please take a photo with your device.",
|
||||
zh: "摄像头需要安全连接(HTTPS)。请直接使用手机拍摄或上传照片。"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(
|
||||
t({
|
||||
en: "Live camera is not supported. Please click Upload to take a photo.",
|
||||
zh: "当前环境不支持实时取景,请点击右下角按钮直接拍照或上传。"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
|
||||
let newStream: MediaStream | null = null;
|
||||
try {
|
||||
const primaryConstraints: MediaStreamConstraints = {
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: targetFacingMode },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
};
|
||||
newStream = await navigator.mediaDevices.getUserMedia(primaryConstraints);
|
||||
} catch (tier1Error: any) {
|
||||
try {
|
||||
const secondaryConstraints: MediaStreamConstraints = {
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: targetFacingMode },
|
||||
},
|
||||
};
|
||||
newStream = await navigator.mediaDevices.getUserMedia(secondaryConstraints);
|
||||
} catch (tier2Error: any) {
|
||||
newStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!newStream) {
|
||||
throw new Error("Unable to acquire media stream.");
|
||||
}
|
||||
|
||||
streamRef.current = newStream;
|
||||
setStream(newStream);
|
||||
|
||||
if (videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
video.srcObject = newStream;
|
||||
video.setAttribute("playsinline", "true");
|
||||
video.setAttribute("webkit-playsinline", "true");
|
||||
video.onloadedmetadata = () => {
|
||||
video.play().catch((playErr) => {
|
||||
console.log("[Camera] Video play deferred:", playErr);
|
||||
});
|
||||
};
|
||||
}
|
||||
checkCameraDevices();
|
||||
} catch (err: any) {
|
||||
console.error("Camera access error:", err);
|
||||
let errorMsg = t({
|
||||
en: "Camera access was denied or not found. Please click Upload to take/choose a photo.",
|
||||
zh: "无法访问摄像头。请点击下方上传按钮直接拍照或从相册选择。"
|
||||
});
|
||||
setCameraError(errorMsg);
|
||||
}
|
||||
}, [stopStream, checkCameraDevices, t]);
|
||||
|
||||
useEffect(() => {
|
||||
startCamera(facingMode);
|
||||
return () => {
|
||||
stopStream();
|
||||
};
|
||||
}, [facingMode, startCamera, stopStream]);
|
||||
|
||||
const toggleCameraFacingMode = async () => {
|
||||
if (isSwitchingCamera) return;
|
||||
setIsSwitchingCamera(true);
|
||||
const nextMode = facingMode === "user" ? "environment" : "user";
|
||||
setFacingMode(nextMode);
|
||||
setTimeout(() => {
|
||||
setIsSwitchingCamera(false);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleCapture = () => {
|
||||
if (!videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
const videoWidth = video.videoWidth || 1080;
|
||||
const videoHeight = video.videoHeight || 1920;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (facingMode === "user") {
|
||||
ctx.translate(canvas.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const photoBase64 = canvas.toDataURL("image/jpeg", 0.92);
|
||||
|
||||
setIsFlashing(true);
|
||||
setTimeout(() => {
|
||||
setIsFlashing(false);
|
||||
stopStream();
|
||||
onPhotoCaptured(photoBase64);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const startCountdown = () => {
|
||||
if (countdown !== null) return;
|
||||
const seconds = config.camera.countdownSeconds || 3;
|
||||
setCountdown(seconds);
|
||||
|
||||
let remaining = seconds;
|
||||
const interval = setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining > 0) {
|
||||
setCountdown(remaining);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setCountdown(null);
|
||||
handleCapture();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
stopStream();
|
||||
onPhotoCaptured(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col items-center justify-between p-2 sm:p-4 overflow-hidden select-none">
|
||||
{/* Top Header */}
|
||||
<div className="w-full flex items-center justify-between px-2 mb-1 z-30 min-h-[40px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
stopStream();
|
||||
onBack();
|
||||
}}
|
||||
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-xs cursor-pointer shadow-md"
|
||||
>
|
||||
<ArrowLeft size={16} className="stroke-[3]" />
|
||||
<span>{t(config.labels.backButton)}</span>
|
||||
</button>
|
||||
|
||||
{hasMultipleCameras ? (
|
||||
<button
|
||||
onClick={toggleCameraFacingMode}
|
||||
disabled={isSwitchingCamera || countdown !== null}
|
||||
className="p-2.5 rounded-2xl btn-cartoon-dark text-amber-300 hover:text-white transition active:scale-95 shadow-md"
|
||||
title="Switch front/back camera"
|
||||
>
|
||||
<SwitchCamera size={18} className={`stroke-[2.5] ${isSwitchingCamera ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video Viewport - Made bigger, flex-1 with Mascot overlaying */}
|
||||
<div className="relative w-full max-w-md flex-1 min-h-0 rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-1">
|
||||
{cameraError ? (
|
||||
<div className="p-6 text-center text-red-200 flex flex-col items-center justify-center space-y-4">
|
||||
<AlertCircle size={44} className="text-amber-400 stroke-[2.5]" />
|
||||
<p className="text-xs sm:text-sm font-bold leading-relaxed">{cameraError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={`w-full h-full object-cover ${facingMode === "user" ? "scale-x-[-1]" : ""}`}
|
||||
/>
|
||||
{isFlashing && <div className="absolute inset-0 bg-white z-50 animate-flash" />}
|
||||
{countdown !== null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-[2px] z-40">
|
||||
<span className="text-8xl sm:text-9xl font-black text-amber-400 animate-ping drop-shadow-[0_8px_0_#090314]">
|
||||
{countdown}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Martial Framing Guides */}
|
||||
<div className="absolute inset-x-6 top-6 bottom-20 pointer-events-none border border-dashed border-amber-400/30 rounded-2xl flex flex-col justify-start items-center py-3">
|
||||
<span className="text-[10px] font-black tracking-widest text-amber-300/60 uppercase">
|
||||
⚡ {t({ en: "HEAD & MARTIAL POSE", zh: "头部与武道姿势区域" })} ⚡
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mascot Character - Overlaying bottom-left of the picture frame */}
|
||||
<div className="absolute -bottom-4 -left-3 sm:-bottom-2 sm:-left-2 z-30 pointer-events-none">
|
||||
<MascotGuide
|
||||
pose="kick"
|
||||
size="lg"
|
||||
speechPosition="right"
|
||||
speechText={{
|
||||
en: "Strike your pose! Punch, kick, or victory stance!",
|
||||
zh: "摆出你的武道英姿!冲拳、高踢或胜利架势!",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<div className="w-full max-w-sm flex items-center justify-between px-6 py-2 z-20 min-h-[76px]">
|
||||
{/* Upload / Device Photo Button */}
|
||||
{config.camera.allowUpload ? (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3.5 rounded-2xl btn-cartoon-dark text-amber-300 transition active:scale-95 shadow-md flex items-center justify-center"
|
||||
title={t(config.labels.cameraUpload)}
|
||||
>
|
||||
<Upload size={20} className="stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12" />
|
||||
)}
|
||||
|
||||
{/* Shutter Button */}
|
||||
<button
|
||||
onClick={startCountdown}
|
||||
disabled={Boolean(cameraError) || countdown !== null || !stream}
|
||||
className="w-18 h-18 sm:w-20 sm:h-20 rounded-full btn-cartoon-orange flex items-center justify-center disabled:opacity-50 active:scale-95 shadow-lg"
|
||||
title={t(config.labels.cameraSnap)}
|
||||
>
|
||||
<Camera size={32} className="text-white drop-shadow-[0_2px_0_#9a3412]" />
|
||||
</button>
|
||||
|
||||
{/* Balance spacer */}
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ArrowLeft, Check, RefreshCw } from "lucide-react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface ConfirmScreenProps {
|
||||
config: BoothConfig;
|
||||
photoUrl: string;
|
||||
onConfirm: () => void;
|
||||
onRetake: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
|
||||
config,
|
||||
photoUrl,
|
||||
onConfirm,
|
||||
onRetake,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col items-center justify-between p-3 sm:p-4 overflow-hidden select-none">
|
||||
{/* Top Header - Back/Retake button on top left, title removed */}
|
||||
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
|
||||
<button
|
||||
onClick={onRetake}
|
||||
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-xs cursor-pointer shadow-md"
|
||||
>
|
||||
<ArrowLeft size={16} className="stroke-[3]" />
|
||||
<span>{t(config.labels.retakeButton)}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
{/* Captured Photo Preview Card */}
|
||||
<div className="relative w-full max-w-sm aspect-[9/16] rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-auto">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt="Captured Pose"
|
||||
className="w-full h-full object-cover select-none"
|
||||
/>
|
||||
|
||||
{/* Mascot Comic Callout in preview */}
|
||||
<div className="absolute -bottom-2 -left-2 sm:bottom-1 sm:left-1 pointer-events-none z-20">
|
||||
<MascotGuide
|
||||
pose="bow"
|
||||
size="md"
|
||||
speechPosition="right"
|
||||
speechText={{
|
||||
en: "Epic martial focus! Ready to pick your style?",
|
||||
zh: "气势十足!准备好选择你的万圣风格了吗?",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Bar - Single centered button matching Standby Screen style */}
|
||||
<div className="w-full max-w-sm flex items-center justify-center py-2 z-20 min-h-[70px]">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg active:scale-95"
|
||||
>
|
||||
<Check size={17} className="stroke-[3]" />
|
||||
<span>{t(config.labels.confirmButton)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Sparkles, Loader2, Flame } from "lucide-react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface LoadingScreenProps {
|
||||
config: BoothConfig;
|
||||
currentStepMessage?: string;
|
||||
}
|
||||
|
||||
export const LoadingScreen: React.FC<LoadingScreenProps> = ({
|
||||
config,
|
||||
currentStepMessage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [progress, setProgress] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 92) return prev;
|
||||
return prev + Math.floor(Math.random() * 8) + 3;
|
||||
});
|
||||
}, 700);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center p-6 text-center select-none overflow-hidden">
|
||||
{/* Animated Center Karate Ghost & Ki-Sphere */}
|
||||
<div className="relative mb-4 flex flex-col items-center">
|
||||
<MascotGuide
|
||||
pose="punch"
|
||||
size="md"
|
||||
speechPosition="top"
|
||||
speechText={{
|
||||
en: "KIAI! Gathering dojo ki and pumpkin magic...",
|
||||
zh: "气合!正在汇聚道馆灵气与南瓜魔法...",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white tracking-wide mb-2 drop-shadow-[0_2px_0_#090314]">
|
||||
{t(config.labels.processingTitle)}
|
||||
</h2>
|
||||
|
||||
<p className="text-xs sm:text-sm text-purple-200 max-w-xs mb-6 font-medium leading-relaxed drop-shadow">
|
||||
{currentStepMessage || t(config.labels.processingSubtitle)}
|
||||
</p>
|
||||
|
||||
{/* Chunky Progress Bar */}
|
||||
<div className="w-full max-w-xs h-6 rounded-full bg-black/60 border-2 border-black p-1 shadow-inner relative overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-amber-400 via-orange-500 to-cyan-400 transition-all duration-300 shadow-[0_0_15px_rgba(251,191,36,0.6)]"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-black text-amber-300 mt-2 tracking-wider">
|
||||
{progress}% · {t({ en: "MARTIAL MAGIC BREWING", zh: "武道气合凝聚中" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface RateLimitedScreenProps {
|
||||
config: BoothConfig;
|
||||
resetMinutes: number;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const RateLimitedScreen: React.FC<RateLimitedScreenProps> = ({
|
||||
config,
|
||||
resetMinutes,
|
||||
onReset,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center p-8 text-center select-none space-y-6">
|
||||
<div className="w-24 h-24 rounded-full bg-gradient-to-tr from-purple-900 to-orange-950 flex items-center justify-center border-2 border-orange-500/40 shadow-[0_0_30px_rgba(255,109,0,0.3)]">
|
||||
<span className="text-4xl animate-bounce">⏳</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-2xl font-black text-white tracking-wide drop-shadow-[0_2px_0_#090314]">
|
||||
{t({ en: "Martial Energy Cooling Down! ⚡", zh: "武道气合修整中!⚡" })}
|
||||
</h2>
|
||||
<p className="text-amber-200/90 text-sm leading-relaxed font-bold">
|
||||
{t({
|
||||
en: "You have conjured the maximum number of Halloween portraits for this session.",
|
||||
zh: "当前设备生成的万圣武道照已达上限,稍作休息吧!"
|
||||
})}
|
||||
</p>
|
||||
{resetMinutes > 0 && (
|
||||
<p className="text-slate-300 text-xs mt-2 bg-black/40 px-3 py-1.5 rounded-full border border-purple-500/30">
|
||||
{t({
|
||||
en: `More energy ready in approx ${resetMinutes} minutes.`,
|
||||
zh: `预计约 ${resetMinutes} 分钟后恢复。`
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-6 py-3.5 rounded-2xl btn-cartoon-orange text-white font-black text-sm shadow-xl flex items-center space-x-2 transition active:scale-95"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
<span>{t({ en: "Return to Dojo 🥋", zh: "返回万圣道馆 🥋" })}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface ResultScreenProps {
|
||||
config: BoothConfig;
|
||||
resultImageUrl: string;
|
||||
providerInfo?: {
|
||||
provider: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
fallbackTriggered: boolean;
|
||||
};
|
||||
onStartOver?: () => void;
|
||||
}
|
||||
|
||||
export const ResultScreen: React.FC<ResultScreenProps> = ({
|
||||
config,
|
||||
resultImageUrl,
|
||||
providerInfo,
|
||||
onStartOver,
|
||||
}) => {
|
||||
const { t, isWeChat } = useI18n();
|
||||
const [currentUrl, setCurrentUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
spread: 85,
|
||||
origin: { y: 0.65 },
|
||||
colors: ["#ff6d00", "#06b6d4", "#a855f7", "#fbbf24", "#ffffff"],
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
setCurrentUrl(window.location.href);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownload = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = resultImageUrl;
|
||||
a.download = `shanghai-karate-spooky-${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col justify-between p-2 sm:p-3 overflow-hidden select-none">
|
||||
{/* Top spacing */}
|
||||
<div className="w-full min-h-[4px] sm:min-h-[8px]" />
|
||||
|
||||
{/* Main Result Image Card - Compact 9:16 frame */}
|
||||
<div className="relative w-full max-w-[275px] sm:max-w-[310px] mx-auto aspect-[9/16] rounded-2xl sm:rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-auto">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resultImageUrl}
|
||||
alt="Halloween Karate Result"
|
||||
className="w-full h-full object-cover select-none"
|
||||
/>
|
||||
|
||||
{/* WeChat Long-Press Save Floating Badge */}
|
||||
{isWeChat && (
|
||||
<div className="absolute top-2 inset-x-3 py-1 px-3 rounded-full bg-black/75 backdrop-blur-sm border border-amber-400/50 text-center">
|
||||
<span className="text-[10px] sm:text-[11px] font-bold text-amber-300">
|
||||
👉 {t(config.labels.wechatSaveTip)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar: Mascot on far bottom-left, Save Button on bottom-right */}
|
||||
<div className="w-full max-w-[340px] sm:max-w-md mx-auto flex items-end justify-between z-30 -mt-10 sm:-mt-12 px-1 pb-1">
|
||||
{/* Mascot hugging the far bottom-left with speech bubble to the right and lifted higher */}
|
||||
<div className="pointer-events-none -ml-4 sm:-ml-2 -mb-1">
|
||||
<MascotGuide
|
||||
pose="zen"
|
||||
size="md"
|
||||
speechPosition="right"
|
||||
speechClassName="-translate-y-10 sm:-translate-y-8 max-w-[340px] sm:max-w-[400px]"
|
||||
speechText={{
|
||||
en: "Sensational! A true Shanghai Halloween Master!",
|
||||
zh: "太赞了!真正的万圣空手道宗师诞生!",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button on the bottom-right */}
|
||||
<div className="pb-1.5 shrink-0">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center space-x-1.5 uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Download size={15} className="stroke-[2.5]" />
|
||||
<span>{t(config.labels.downloadLabel)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface StandbyScreenProps {
|
||||
config: BoothConfig;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
export const StandbyScreen: React.FC<StandbyScreenProps> = ({
|
||||
config,
|
||||
onStart,
|
||||
}) => {
|
||||
const { labels } = config;
|
||||
const { t, language } = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onStart}
|
||||
className="relative flex-1 flex flex-col items-center justify-between px-3 py-2 sm:px-6 sm:py-4 cursor-pointer select-none overflow-hidden"
|
||||
>
|
||||
{/* Center Hero Character - Clean 3D Mascot */}
|
||||
<div className="my-auto flex flex-col items-center text-center z-10 space-y-2 sm:space-y-3 px-2 max-w-sm">
|
||||
{/* Title & Subtitle with Stylized Typography */}
|
||||
<div className="space-y-0.5">
|
||||
<h2 className={`text-2xl sm:text-3xl md:text-4xl font-black text-amber-100 tracking-wider drop-shadow-[0_4px_12px_rgba(0,0,0,0.95)] ${language === 'en' ? 'font-cinzel' : 'font-dojo'}`}>
|
||||
{t(labels.welcomeTitle)}
|
||||
</h2>
|
||||
<p className="text-amber-400/95 font-semibold text-[11px] sm:text-xs tracking-widest uppercase drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]">
|
||||
{t(labels.welcomeSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Floating 3D Karate Ghost Mascot with Comic Speech Bubble */}
|
||||
<MascotGuide
|
||||
pose="bow"
|
||||
size="lg"
|
||||
speechPosition="top"
|
||||
className="my-1"
|
||||
speechText={{
|
||||
en: "Osu! Welcome to our dojo! Strike your fiercest martial arts pose & become an epic Shanghai legend!",
|
||||
zh: "押忍!欢迎来到道馆!摆出你最帅气的武道姿势,成为魔都万圣传奇!",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Halloween Game Action Button */}
|
||||
<div className="z-10 w-full flex flex-col items-center pb-1">
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg"
|
||||
>
|
||||
<span>{t(labels.welcomeCta)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Check, ArrowLeft, Wand2 } from "lucide-react";
|
||||
import { BoothConfig, BoothStyle } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
|
||||
interface StyleScreenProps {
|
||||
config: BoothConfig;
|
||||
styles: BoothStyle[];
|
||||
onStyleSelected: (style: BoothStyle) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const StyleScreen: React.FC<StyleScreenProps> = ({
|
||||
config,
|
||||
styles,
|
||||
onStyleSelected,
|
||||
onBack,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedId, setSelectedId] = useState<string>(styles[0]?.id || "");
|
||||
|
||||
const handleConfirm = () => {
|
||||
const chosen = styles.find((s) => s.id === selectedId) || styles[0];
|
||||
if (chosen) {
|
||||
onStyleSelected(chosen);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStyle = styles.find((s) => s.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col justify-between p-3 sm:p-4 overflow-hidden select-none">
|
||||
{/* Top Header - Back on left, Confirm on right */}
|
||||
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 px-3 game-btn-dark flex items-center space-x-1 font-bold text-xs shrink-0 cursor-pointer shadow-md"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft size={16} className="stroke-[2.5]" />
|
||||
<span>{t(config.labels.backButton)}</span>
|
||||
</button>
|
||||
|
||||
{/* Top-Right Confirm / Transform Button - Clean text, no icon */}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs tracking-wider flex items-center justify-center uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t(config.labels.confirmStyleButton)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Styles Grid (2x2) with Game Stone/Wood Frame Card Styling - Moved down with spacing */}
|
||||
<div className="w-full max-w-sm mx-auto grid grid-cols-2 gap-3 z-20 mt-3 mb-auto py-1">
|
||||
{styles.map((style) => {
|
||||
const isSelected = selectedId === style.id;
|
||||
const styleName = t(style.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={style.id}
|
||||
onClick={() => setSelectedId(style.id)}
|
||||
className={`relative flex flex-col items-center p-2.5 rounded-2xl cursor-pointer transition-all duration-200 ${
|
||||
isSelected
|
||||
? "game-panel-amber ring-2 ring-amber-400 scale-[1.03] shadow-[0_8px_25px_rgba(249,115,22,0.6)]"
|
||||
: "game-panel opacity-85 hover:opacity-100 hover:border-purple-400/80"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-2.5 -right-2.5 w-7 h-7 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 border-2 border-black flex items-center justify-center text-black z-30 shadow-[0_2px_8px_rgba(0,0,0,0.8)]">
|
||||
<Check size={16} className="stroke-[3.5]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artwork Thumbnail */}
|
||||
<div className="relative w-full aspect-square rounded-xl overflow-hidden bg-black/80 border-2 border-[#3d1e6d] shadow-inner">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={style.thumbnail}
|
||||
alt={styleName}
|
||||
className="w-full h-full object-cover select-none"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title & Tag */}
|
||||
<div className="mt-2 text-center w-full px-1">
|
||||
<span className="block text-xs font-bold text-amber-100 truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
{styleName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Style Description Card & Mascot Comic Guide - Lifted up to slightly overlay bottom of style cards */}
|
||||
<div className="w-full max-w-sm mx-auto z-30 flex items-center justify-start gap-1 px-1 -mt-5 sm:-mt-6 pb-2 pointer-events-none">
|
||||
<MascotGuide
|
||||
pose="punch"
|
||||
size="md"
|
||||
speechPosition="right"
|
||||
speechText={{
|
||||
en: "Choose your legendary martial transformation!",
|
||||
zh: "挑选你专属的万圣节武道变身奥义!",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"appName": "Spooky Hand · 万圣空手",
|
||||
"kioskMode": true,
|
||||
"theme": {
|
||||
"backgroundType": "image",
|
||||
"backgroundColor": "#0b0714",
|
||||
"backgroundVideoUrl": "",
|
||||
"backgroundImageUrl": "/assets/theme/shanghai_dojo_night_bg.jpg",
|
||||
"primaryColor": "#ff6d00",
|
||||
"accentColor": "#06b6d4",
|
||||
"surfaceColor": "#170f29",
|
||||
"textColor": "#ffffff",
|
||||
"logoUrl": "/assets/brand/shanghai_karate_logo.png",
|
||||
"logoWidth": 310
|
||||
},
|
||||
"camera": {
|
||||
"defaultFacingMode": "user",
|
||||
"aspectRatio": "9:16",
|
||||
"countdownSeconds": 3,
|
||||
"allowUpload": true,
|
||||
"maxUploadSizeMb": 10
|
||||
},
|
||||
"features": {
|
||||
"showQrCode": true,
|
||||
"showDownloadButton": true,
|
||||
"showPrintButton": false,
|
||||
"showRetakeButton": true,
|
||||
"showInstagramShare": false
|
||||
},
|
||||
"limits": {
|
||||
"maxGenerationsPerDevice": 0,
|
||||
"cooldownMinutes": 60
|
||||
},
|
||||
"ai": {
|
||||
"primary": {
|
||||
"provider": "gemini",
|
||||
"model": "gemini-3.1-flash-image",
|
||||
"timeoutMs": 28000
|
||||
},
|
||||
"backup": {
|
||||
"provider": "openrouter",
|
||||
"model": "google/gemini-3.1-flash-image",
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
"cooldownDurationMinutes": 10,
|
||||
"enableSfwCheck": true
|
||||
},
|
||||
"kiosk": {
|
||||
"enableAutoReset": true,
|
||||
"idleTimeoutSeconds": 90
|
||||
},
|
||||
"labels": {
|
||||
"welcomeBadge": {
|
||||
"en": "SHANGHAI KARATE CLUB • EST. 2008",
|
||||
"zh": "上海健志空手道馆 · 始于2008"
|
||||
},
|
||||
"welcomeTitle": {
|
||||
"en": "SPOOKY HAND",
|
||||
"zh": "万圣空手道馆"
|
||||
},
|
||||
"welcomeSubtitle": {
|
||||
"en": "Channel Your Spooky Ki & Martial Pose",
|
||||
"zh": "凝聚万圣气合 · 定格武道英姿"
|
||||
},
|
||||
"welcomeDesc": {
|
||||
"en": "Strike your fiercest martial arts pose! Turn your Halloween costume or karate gi into an epic Shanghai legend.",
|
||||
"zh": "摆出你最帅气的武道姿势!无论身穿万圣盛装还是空手道服,一键变身魔都万圣武道传奇!"
|
||||
},
|
||||
"welcomeCta": {
|
||||
"en": "ENTER THE HAUNTED DOJO",
|
||||
"zh": "开启万圣武道之旅"
|
||||
},
|
||||
"cameraInstruction": {
|
||||
"en": "Strike a punch, block, or karate victory pose",
|
||||
"zh": "摆出冲拳、格挡或胜利姿态,展现你的气魄"
|
||||
},
|
||||
"cameraSnap": {
|
||||
"en": "Snap Martial Shot",
|
||||
"zh": "拍摄武道照"
|
||||
},
|
||||
"cameraUpload": {
|
||||
"en": "Upload Photo",
|
||||
"zh": "上传照片"
|
||||
},
|
||||
"confirmTitle": {
|
||||
"en": "Review Your Martial Shot",
|
||||
"zh": "确认你的英姿"
|
||||
},
|
||||
"retakeButton": {
|
||||
"en": "Retake",
|
||||
"zh": "重拍"
|
||||
},
|
||||
"confirmButton": {
|
||||
"en": "Looks Epic!",
|
||||
"zh": "超帅气!"
|
||||
},
|
||||
"styleSelectionTitle": {
|
||||
"en": "Select Your Halloween Kata",
|
||||
"zh": "选择你的万圣奥义风格"
|
||||
},
|
||||
"styleSelectionSubtitle": {
|
||||
"en": "Choose your legendary martial transformation",
|
||||
"zh": "挑选专属的万圣节武道变身效果"
|
||||
},
|
||||
"confirmStyleButton": {
|
||||
"en": "CAST MARTIAL MAGIC",
|
||||
"zh": "施展武道魔法"
|
||||
},
|
||||
"backButton": {
|
||||
"en": "Back",
|
||||
"zh": "返回"
|
||||
},
|
||||
"processingTitle": {
|
||||
"en": "Gathering Martial Ki & Spooky Spirits...",
|
||||
"zh": "正在凝聚万圣之气与道馆灵力..."
|
||||
},
|
||||
"processingSubtitle": {
|
||||
"en": "Summoning Huangpu mist, pumpkin embers & black belt magic...",
|
||||
"zh": "融合黄浦江晨雾、南瓜火光与黑带武道魔法..."
|
||||
},
|
||||
"resultTitle": {
|
||||
"en": "Your Spooky Martial Portrait",
|
||||
"zh": "你的万圣武道神采"
|
||||
},
|
||||
"scanQrLabel": {
|
||||
"en": "Scan to save to your phone",
|
||||
"zh": "扫码保存至手机相册"
|
||||
},
|
||||
"downloadLabel": {
|
||||
"en": "Save Portrait",
|
||||
"zh": "保存高清照"
|
||||
},
|
||||
"wechatSaveTip": {
|
||||
"en": "In WeChat: Long press photo above to save directly!",
|
||||
"zh": "微信提示:长按上方图片即可直接保存到相册!"
|
||||
},
|
||||
"printLabel": {
|
||||
"en": "Print Photo",
|
||||
"zh": "打印照片"
|
||||
},
|
||||
"startOverLabel": {
|
||||
"en": "Train Again (Start Over)",
|
||||
"zh": "再拍一张"
|
||||
},
|
||||
"instagramShareLabel": {
|
||||
"en": "Share Photo",
|
||||
"zh": "分享照片"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": "bund_vampire_master",
|
||||
"name": {
|
||||
"en": "Bund Vampire Master",
|
||||
"zh": "外滩吸血鬼武道家"
|
||||
},
|
||||
"description": {
|
||||
"en": "Striking karate crane pose with a crimson velvet cape & martial belt overlooking the moonlit Huangpu River.",
|
||||
"zh": "优雅的吸血鬼天鹅绒斗篷与专属道带,夜眺黄浦江画舫与皎洁明月。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/bund_vampire_master.jpg",
|
||||
"sampleResult": "/assets/styles/bund_vampire_master.jpg",
|
||||
"prompt": "CRITICAL STRICT FACIAL IDENTITY PRESERVATION (ABSOLUTE HIGHEST PRIORITY): You MUST preserve the exact recognizable face of the person in the input photo. Strictly copy and preserve their identical facial bone structure, exact eye shape, eyelid fold, iris color, eyebrow shape and angle, nose bridge and nostril shape, mouth shape, authentic smile, teeth line, cheeks, ears, chin, skin complexion, hair texture, natural hair color, and any glasses. Friends and parents MUST instantly recognize this exact person without hesitation. STYLING: Translate them into a high-end 3D animated feature film character (in the visual fidelity of Pixar, Hotel Transylvania, and DreamWorks). Do NOT change their face into a generic cartoon face. COSTUME & POSE: The person strikes an energetic karate crane stance or martial fist guard. If they are wearing a Halloween costume (superhero suit, witch dress, skeleton, etc.), keep their costume completely intact, elegantly adding a luxurious crimson velvet vampire cape over their shoulders and a martial arts karate belt (Obi) tied around their waist. They have a happy, genuine smile with cute subtle vampire mini-fangs. Background: An authentic moonlit wooden rooftop dojo overlooking the misty Huangpu River with traditional lantern-lit vintage Shanghai boats beneath an enormous golden full moon and cute cartoon bats fluttering overhead. Warm whimsical cartoon lighting, zero weapons, zero headbands, zero flags, family-friendly.",
|
||||
"negativePrompt": "unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic flesh, creepy horror, scary, bloody, blood, gore, violent, deformed, extra fingers, weapons, swords, katana, japanese flag, headband, text watermark"
|
||||
},
|
||||
{
|
||||
"id": "jack_o_lantern_sensei",
|
||||
"name": {
|
||||
"en": "Jack-o'-Lantern Black Belt",
|
||||
"zh": "南瓜黑带宗师"
|
||||
},
|
||||
"description": {
|
||||
"en": "Swirling pumpkin ember shields and autumn martial flame ki with the glowing Oriental Pearl Tower in the skyline.",
|
||||
"zh": "双拳凝聚流光火光结界,背景为圆月下的东方明珠与魔都夜景。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/jack_o_lantern_sensei.jpg",
|
||||
"sampleResult": "/assets/styles/jack_o_lantern_sensei.jpg",
|
||||
"prompt": "CRITICAL STRICT FACIAL IDENTITY PRESERVATION (ABSOLUTE HIGHEST PRIORITY): You MUST preserve the exact recognizable face of the person in the input photo. Strictly copy and preserve their identical facial bone structure, exact eye shape, eyelid fold, iris color, eyebrow shape and angle, nose bridge and nostril shape, mouth shape, authentic smile, teeth line, cheeks, ears, chin, skin complexion, hair texture, natural hair color, and any glasses. Friends and parents MUST instantly recognize this exact person without hesitation. STYLING: Translate them into a high-end 3D animated feature film character (in the visual fidelity of Pixar, DreamWorks, and Hotel Transylvania). Do NOT change their face into a generic cartoon face. COSTUME LOGIC: If the person is already wearing a Halloween costume (superhero, spider suit, witch, skeleton, etc.), preserve their costume styling completely while seamlessly adding an authentic karate martial arts belt (Obi) around their waist. If in regular clothes/gi, dress them in a clean white martial arts uniform. MARTIAL POSE & MAGIC: Energetic karate punch or forward guard stance. Their hands channel swirling magical pumpkin embers and mystical glowing autumn flame shields (like martial arts energy orbs). Beside them on the tatami mat is a cute animated smiling jack-o'-lantern giving a thumbs-up. Background: An authentic traditional wooden courtyard dojo with warm lanterns overlooking the iconic glowing Oriental Pearl Tower in Shanghai under a purple twilight Halloween harvest moon. Warm cinematic lighting, crisp 3D cartoon render, completely family-friendly, zero weapons, zero headbands, zero flags.",
|
||||
"negativePrompt": "unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic composite, flat image, distorted face, monster, scary, blood, gore, weapons, sword, katana, imperial flag, headband, text watermark"
|
||||
},
|
||||
{
|
||||
"id": "ink_wash_cyber_shinobi",
|
||||
"name": {
|
||||
"en": "Cyber Kiai Shinobi",
|
||||
"zh": "水墨神拳·气合波"
|
||||
},
|
||||
"description": {
|
||||
"en": "Electric cyan & neon purple chi trails with calligraphic ink mist along the historic illuminated Shanghai Bund.",
|
||||
"zh": "双拳凝聚青紫电光气合波与水墨风云,经典外滩夜色与纸折小蝙蝠。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/ink_wash_cyber_shinobi.jpg",
|
||||
"sampleResult": "/assets/styles/ink_wash_cyber_shinobi.jpg",
|
||||
"prompt": "CRITICAL STRICT FACIAL IDENTITY PRESERVATION (ABSOLUTE HIGHEST PRIORITY): You MUST preserve the exact recognizable face of the person in the input photo. Strictly copy and preserve their identical facial bone structure, exact eye shape, eyelid fold, iris color, eyebrow shape and angle, nose bridge and nostril shape, mouth shape, authentic smile, teeth line, cheeks, ears, chin, skin complexion, hair texture, natural hair color, and any glasses. Friends and parents MUST instantly recognize this exact person without hesitation. STYLING: Convert into a high-end 3D animated feature film character in the dynamic visual style of Spider-Verse meets Pixar. COSTUME & POSE: The person strikes an explosive karate reverse-punch (Gyaku-tsuki) or flying side-kick. If they wear a Halloween costume, preserve its design and colors, adding a sleek black karate martial arts belt (Obi) around their waist. Sweeping around their strike are vibrant electric cyan and neon-purple martial arts Ki (spirit energy) trails and stylized calligraphy brushstrokes. Background: The historic illuminated architecture of the Shanghai Bund along the misty Huangpu River under a dramatic moonlit Halloween sky with playful origami paper bats. Vibrant colors, crisp 3D character render, heroic and empowering, no weapons, no headbands, no rising sun motifs.",
|
||||
"negativePrompt": "unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, distorted face, dull colors, weapons, katana, swords, japanese imperial flag, rising sun, headband, hachimaki, scary, bloody, ugly, deformed limbs, watermark"
|
||||
},
|
||||
{
|
||||
"id": "ghostly_kiai_dojo",
|
||||
"name": {
|
||||
"en": "Tatami Ghost Sensei",
|
||||
"zh": "道馆小幽灵·型"
|
||||
},
|
||||
"description": {
|
||||
"en": "Playful martial Kata pose cheered on by mini karate ghosts tossing festive candies on the dojo tatami.",
|
||||
"zh": "胖嘟嘟的棉花糖小幽灵系着迷你道带,在榻榻米上为你喝彩抛糖果。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/ghostly_kiai_dojo.jpg",
|
||||
"sampleResult": "/assets/styles/ghostly_kiai_dojo.jpg",
|
||||
"prompt": "CRITICAL STRICT FACIAL IDENTITY PRESERVATION (ABSOLUTE HIGHEST PRIORITY): You MUST preserve the exact recognizable face of the person in the input photo. Strictly copy and preserve their identical facial bone structure, exact eye shape, eyelid fold, iris color, eyebrow shape and angle, nose bridge and nostril shape, mouth shape, authentic smile, teeth line, cheeks, ears, chin, skin complexion, hair texture, natural hair color, and any glasses. Friends and parents MUST instantly recognize this exact person without hesitation. STYLING: Rendered as a warm, lovable 3D animated character in the aesthetic of Pixar and Hotel Transylvania. COSTUME & POSE: The child strikes an energetic karate victory Kata pose with a martial arts belt tied over their costume or karate dogi. GHOSTLY BUDDIES: Surrounding the child on the clean dojo tatami mats are 3 to 4 chubby, adorable, expressive marshmallow-like cartoon baby ghosts—each playfully wearing little miniature colored karate belts (yellow, orange, green) and comically imitating the child's martial pose while floating Halloween candies and mini pumpkins drift around. Background: Inside a cozy traditional wooden Shanghai dojo with glowing carved pumpkin lanterns, paper screens, and a night window showing the Shanghai skyline. Heartwarming, funny, high quality 3D cartoon render, zero weapons, zero flags.",
|
||||
"negativePrompt": "unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, terrifying, scary ghost, evil demon, blood, gore, horror, distorted face, changed identity, weapons, swords, katana, rising sun, headband, blurry, deformed limbs, watermark"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh - Update and restart YEG SpookyBooth
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting deployment..."
|
||||
|
||||
# Navigate to project directory
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 1. Pull latest code (if using git on server)
|
||||
if [ -d ".git" ]; then
|
||||
echo "📥 Pulling latest git commits..."
|
||||
git pull
|
||||
fi
|
||||
|
||||
# 2. Install dependencies (devDependencies needed for next build & tailwind)
|
||||
echo "📦 Installing npm dependencies..."
|
||||
npm ci || npm install
|
||||
|
||||
# 3. Build Next.js application
|
||||
echo "🔨 Building production Next.js build..."
|
||||
npm run build
|
||||
|
||||
# 4. Start or reload PM2
|
||||
if pm2 list | grep -q "photobooth_web_v1"; then
|
||||
echo "🔄 Reloading PM2 process..."
|
||||
pm2 reload ecosystem.config.cjs --update-env
|
||||
else
|
||||
echo "▶️ Starting PM2 process..."
|
||||
pm2 start ecosystem.config.cjs
|
||||
fi
|
||||
|
||||
pm2 save
|
||||
|
||||
echo "✅ Deployment successful! Service running on port 34821."
|
||||
@@ -0,0 +1,149 @@
|
||||
# /etc/nginx/sites-available/test.ph.sokaris.link
|
||||
#
|
||||
# Production Nginx reverse proxy configuration for YEG SpookyBooth
|
||||
# Domain: test.ph.sokaris.link
|
||||
# Backend: Next.js on 127.0.0.1:34821
|
||||
|
||||
# Rate limiting zones (must be placed in http block on server, or included here)
|
||||
limit_req_zone $binary_remote_addr zone=booth_api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=booth_auth_limit:10m rate=5r/m;
|
||||
|
||||
upstream photobooth_backend {
|
||||
server 127.0.0.1:34821;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# HTTP: Redirect all traffic to HTTPS (and allow Certbot ACME challenges)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name test.ph.sokaris.link;
|
||||
|
||||
# Certbot challenge directory
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
# Redirect all other HTTP traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS: Secure proxy block
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name test.ph.sokaris.link;
|
||||
|
||||
# SSL Certificates (Managed by Let's Encrypt / Certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/test.ph.sokaris.link/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/test.ph.sokaris.link/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Maximum upload size for photo uploads (10MB max)
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Camera Access & Security Policy Headers
|
||||
add_header Permissions-Policy "camera=(self), microphone=()" always;
|
||||
add_header Feature-Policy "camera 'self'; microphone 'none'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/photobooth_access.log;
|
||||
error_log /var/log/nginx/photobooth_error.log;
|
||||
|
||||
# 1. Block access to hidden files and directories (.git, .env, etc.)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# 2. Block access to sensitive and executable file extensions
|
||||
location ~* \.(env|git|bak|config|sql|sh|exe|bat|cmd|py|php|cgi)$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Next.js static asset caching (immutable, high performance)
|
||||
location /_next/static/ {
|
||||
proxy_pass http://photobooth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
expires 365d;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Public static assets
|
||||
location /assets/ {
|
||||
proxy_pass http://photobooth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Rate-limited auth endpoint
|
||||
location = /api/portal/auth {
|
||||
limit_req zone=booth_auth_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://photobooth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Rate-limited general API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=booth_api_limit burst=25 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://photobooth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Main Application Proxy
|
||||
location / {
|
||||
proxy_pass http://photobooth_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket & connection upgrade headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Standard Proxy Headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# AI Generation Timeouts (allow up to 2 minutes for slow AI models)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
# Disable buffering for instant streaming / server-sent events
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "photobooth_web_v1",
|
||||
script: "node_modules/next/dist/bin/next",
|
||||
args: "start -p 34821",
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: "1G",
|
||||
interpreter: "node",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 34821,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { constantTimeCompare } from "@/lib/security";
|
||||
|
||||
export function getExpectedToken(): string {
|
||||
const secret = process.env.ADMIN_PASSWORD || "admin123";
|
||||
if (!process.env.ADMIN_PASSWORD && process.env.NODE_ENV === "production") {
|
||||
console.warn("[SECURITY WARNING] ADMIN_PASSWORD is not set in environment variables! Using insecure default.");
|
||||
}
|
||||
return crypto.createHash("sha256").update(`booth-portal-session-${secret}`).digest("hex");
|
||||
}
|
||||
|
||||
export function isAuthenticated(req: NextRequest): boolean {
|
||||
const cookieToken = req.cookies.get("booth_portal_token")?.value;
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
|
||||
|
||||
const expected = getExpectedToken();
|
||||
const tokenToTest = cookieToken || bearerToken || "";
|
||||
|
||||
if (!tokenToTest) return false;
|
||||
|
||||
return constantTimeCompare(tokenToTest, expected);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Provider cooldown is completely disabled: transient failures or safety rejections
|
||||
// will never lock out the API for subsequent requests.
|
||||
export function isProviderAvailable(_providerName: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function triggerCooldown(
|
||||
_providerName: string,
|
||||
_durationMinutes: number,
|
||||
_reason: string
|
||||
): void {
|
||||
// No-op: Cooldown disabled
|
||||
}
|
||||
|
||||
export function resetProvider(_providerName: string): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ContentSafetyError } from "./safety";
|
||||
export interface GeminiGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
enableSfwCheck: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithGemini(
|
||||
options: GeminiGenerateOptions
|
||||
): Promise<GeminiResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
// Clean base64 string
|
||||
const cleanBase64 = options.imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${options.model}:generateContent?key=${apiKey}`;
|
||||
|
||||
const requestBody: any = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: `${options.prompt}\n\nNegative directives: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: options.mimeType || "image/jpeg",
|
||||
data: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (options.enableSfwCheck) {
|
||||
requestBody.safetySettings = [
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HARASSMENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("PROHIBITED_CONTENT") ||
|
||||
errorText.includes("HARM_CATEGORY") ||
|
||||
errorText.includes("SAFETY")
|
||||
) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: PROHIBITED_CONTENT`
|
||||
);
|
||||
}
|
||||
throw new Error(`Gemini API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check safety feedback
|
||||
if (data.promptFeedback?.blockReason) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: ${data.promptFeedback.blockReason}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract generated image from candidates
|
||||
const candidate = data.candidates?.[0];
|
||||
if (!candidate) {
|
||||
throw new Error("Gemini returned no candidates in response");
|
||||
}
|
||||
|
||||
// Check candidate finishReason for safety blocks
|
||||
if (candidate.finishReason === "SAFETY" || candidate.finishReason === "BLOCKLIST") {
|
||||
throw new ContentSafetyError(
|
||||
"Image rejected by content safety filter: output triggered Safe-for-Work (SFW) protection."
|
||||
);
|
||||
}
|
||||
|
||||
// Look for inline image in response parts
|
||||
const parts = candidate.content?.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
const mime = part.inlineData.mimeType || "image/png";
|
||||
return {
|
||||
imageUrl: `data:${mime};base64,${part.inlineData.data}`,
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If text returned or URL in text
|
||||
const textPart = parts.find((p: any) => p.text)?.text || "";
|
||||
const urlMatch = textPart.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Gemini did not return an image part in the response payload. Please verify model image generation capabilities."
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`Gemini request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export interface OpenRouterGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface OpenRouterResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithOpenRouter(
|
||||
options: OpenRouterGenerateOptions
|
||||
): Promise<OpenRouterResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
const cleanBase64 = options.imageBase64.startsWith("data:")
|
||||
? options.imageBase64
|
||||
: `data:${options.mimeType || "image/jpeg"};base64,${options.imageBase64}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://photobooth.local",
|
||||
"X-Title": "AI PhotoBooth",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${options.prompt}\n\nNegative prompt: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenRouter API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
|
||||
// 1. Check OpenRouter image output attachments (message.images: [{ image_url: { url: ... } }])
|
||||
if (Array.isArray(message?.images) && message.images.length > 0) {
|
||||
for (const img of message.images) {
|
||||
const url = img?.image_url?.url || img?.url;
|
||||
if (url) {
|
||||
return {
|
||||
imageUrl: url,
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("OpenRouter returned an empty completion");
|
||||
}
|
||||
|
||||
// Check if content is or contains an image URL or base64 data
|
||||
const base64Match = content.match(/data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+/);
|
||||
if (base64Match) {
|
||||
return {
|
||||
imageUrl: base64Match[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Some models return markdown image: 
|
||||
const mdMatch = content.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
|
||||
if (mdMatch) {
|
||||
return {
|
||||
imageUrl: mdMatch[1],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`OpenRouter response did not contain an image. Output received: ${content.slice(0, 120)}...`
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`OpenRouter request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { BoothConfig, BoothStyle } from "@/types/booth";
|
||||
import { generateWithGemini } from "./gemini";
|
||||
import { generateWithOpenRouter } from "./openrouter";
|
||||
import {
|
||||
isProviderAvailable,
|
||||
triggerCooldown,
|
||||
resetProvider,
|
||||
} from "./circuit-breaker";
|
||||
import { ContentSafetyError, isContentSafetyError } from "./safety";
|
||||
|
||||
export interface OrchestrationRequest {
|
||||
config: BoothConfig;
|
||||
style: BoothStyle;
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface OrchestrationResult {
|
||||
imageUrl: string;
|
||||
providerUsed: "gemini" | "openrouter" | "demo_mock";
|
||||
modelUsed: string;
|
||||
durationMs: number;
|
||||
fallbackTriggered: boolean;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export async function orchestrateGeneration(
|
||||
request: OrchestrationRequest
|
||||
): Promise<OrchestrationResult> {
|
||||
const startTime = Date.now();
|
||||
const { config, style, imageBase64 } = request;
|
||||
const mimeType = request.mimeType || "image/jpeg";
|
||||
const warnings: string[] = [];
|
||||
|
||||
const hasGeminiKey = Boolean(process.env.GEMINI_API_KEY);
|
||||
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
|
||||
|
||||
// Demo / Offline Mode fallback when no API keys are provided yet
|
||||
if (!hasGeminiKey && !hasOpenRouterKey) {
|
||||
console.log("[AI Orchestrator] No API keys configured. Using Demo Mode sample image.");
|
||||
// Simulate generation delay (2 seconds)
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return {
|
||||
imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/sample_clay_pink.png",
|
||||
providerUsed: "demo_mock",
|
||||
modelUsed: "demo-simulator",
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: false,
|
||||
warnings: ["Demo Mode active: Set GEMINI_API_KEY or OPENROUTER_API_KEY in .env.local to enable live AI generation."],
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Try Primary Provider (Google Gemini)
|
||||
if (isProviderAvailable("gemini") && hasGeminiKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Primary (Gemini: ${config.ai.primary.model})...`);
|
||||
const result = await generateWithGemini({
|
||||
model: config.ai.primary.model,
|
||||
prompt: style.prompt,
|
||||
negativePrompt: style.negativePrompt,
|
||||
imageBase64,
|
||||
mimeType,
|
||||
timeoutMs: config.ai.primary.timeoutMs,
|
||||
enableSfwCheck: config.ai.enableSfwCheck,
|
||||
});
|
||||
|
||||
resetProvider("gemini");
|
||||
return {
|
||||
imageUrl: result.imageUrl,
|
||||
providerUsed: "gemini",
|
||||
modelUsed: result.model,
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: false,
|
||||
};
|
||||
} catch (primaryError: any) {
|
||||
// Content safety rejections are caused by user input, NOT provider downtime
|
||||
if (isContentSafetyError(primaryError)) {
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) rejected image for safety:`, primaryError.message);
|
||||
throw new ContentSafetyError(primaryError.message);
|
||||
}
|
||||
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) failed:`, primaryError.message);
|
||||
warnings.push(`Primary failed: ${primaryError.message}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push("Primary provider key missing. Skipping to backup.");
|
||||
}
|
||||
|
||||
// 2. Try Backup Provider (OpenRouter)
|
||||
if (isProviderAvailable("openrouter") && hasOpenRouterKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Backup (OpenRouter: ${config.ai.backup.model})...`);
|
||||
const result = await generateWithOpenRouter({
|
||||
model: config.ai.backup.model,
|
||||
prompt: style.prompt,
|
||||
negativePrompt: style.negativePrompt,
|
||||
imageBase64,
|
||||
mimeType,
|
||||
timeoutMs: config.ai.backup.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
imageUrl: result.imageUrl,
|
||||
providerUsed: "openrouter",
|
||||
modelUsed: result.model,
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: true,
|
||||
warnings,
|
||||
};
|
||||
} catch (backupError: any) {
|
||||
if (isContentSafetyError(backupError)) {
|
||||
console.warn(`[AI Orchestrator] Backup provider (OpenRouter) rejected image for safety:`, backupError.message);
|
||||
throw new ContentSafetyError(backupError.message);
|
||||
}
|
||||
|
||||
console.error(`[AI Orchestrator] Backup provider (OpenRouter) failed:`, backupError.message);
|
||||
warnings.push(`Backup failed: ${backupError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If both providers failed or were unavailable, throw descriptive error
|
||||
throw new Error(
|
||||
`AI Generation failed on all available providers. Details: ${warnings.join(" | ")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export class ContentSafetyError extends Error {
|
||||
public readonly isSafetyRejection = true;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ContentSafetyError";
|
||||
Object.setPrototypeOf(this, ContentSafetyError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export function isContentSafetyError(err: any): boolean {
|
||||
if (!err) return false;
|
||||
if (err.isSafetyRejection || err.name === "ContentSafetyError") return true;
|
||||
|
||||
const msg = String(err.message || err.error || err || "").toLowerCase();
|
||||
return (
|
||||
msg.includes("safety filter") ||
|
||||
msg.includes("prohibited_content") ||
|
||||
msg.includes("harm_category") ||
|
||||
msg.includes("safe-for-work") ||
|
||||
msg.includes("finishreason: safety") ||
|
||||
msg.includes("finishreason: blocklist") ||
|
||||
msg.includes("blockreason") ||
|
||||
msg.includes("content safety") ||
|
||||
msg.includes("safety policy") ||
|
||||
msg.includes("violates safety")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { BoothConfig, BoothStyle } from "@/types/booth";
|
||||
|
||||
export const GENERATION_MODEL_MAP: Record<string, { primary: string; backup: string }> = {
|
||||
"nanobanana 2": {
|
||||
primary: "gemini-3.1-flash-image",
|
||||
backup: "google/gemini-3.1-flash-image",
|
||||
},
|
||||
"nanobanana": {
|
||||
primary: "gemini-2.5-flash-image",
|
||||
backup: "google/gemini-2.5-flash-image",
|
||||
},
|
||||
"lite": {
|
||||
primary: "gemini-3.1-flash-lite-image",
|
||||
backup: "google/gemini-3.1-flash-lite-image",
|
||||
},
|
||||
"nanobanana 2 lite": {
|
||||
primary: "gemini-3.1-flash-lite-image",
|
||||
backup: "google/gemini-3.1-flash-lite-image",
|
||||
},
|
||||
"pro": {
|
||||
primary: "gemini-3-pro-image",
|
||||
backup: "google/gemini-3-pro-image",
|
||||
},
|
||||
"nanobanana pro": {
|
||||
primary: "gemini-3-pro-image",
|
||||
backup: "google/gemini-3-pro-image",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveGenerationModels(): { primary: string; backup: string } {
|
||||
const envModel = (process.env.GENERATION_MODEL || "").trim().toLowerCase();
|
||||
if (envModel && GENERATION_MODEL_MAP[envModel]) {
|
||||
return GENERATION_MODEL_MAP[envModel];
|
||||
}
|
||||
return GENERATION_MODEL_MAP["nanobanana 2"];
|
||||
}
|
||||
|
||||
const defaultConfig: BoothConfig = {
|
||||
appName: "Spooky Hand · 万圣空手",
|
||||
kioskMode: true,
|
||||
theme: {
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#0b0714",
|
||||
primaryColor: "#ff6d00",
|
||||
accentColor: "#06b6d4",
|
||||
surfaceColor: "#170f29",
|
||||
textColor: "#ffffff",
|
||||
logoUrl: "/assets/brand/shanghai_karate_logo.png",
|
||||
logoWidth: 260,
|
||||
},
|
||||
camera: {
|
||||
defaultFacingMode: "user",
|
||||
aspectRatio: "9:16",
|
||||
countdownSeconds: 3,
|
||||
allowUpload: true,
|
||||
maxUploadSizeMb: 10,
|
||||
},
|
||||
features: {
|
||||
showQrCode: true,
|
||||
showDownloadButton: true,
|
||||
showPrintButton: false,
|
||||
showRetakeButton: true,
|
||||
showInstagramShare: false,
|
||||
},
|
||||
limits: {
|
||||
maxGenerationsPerDevice: 0,
|
||||
cooldownMinutes: 60,
|
||||
},
|
||||
ai: {
|
||||
primary: {
|
||||
provider: "gemini",
|
||||
model: "gemini-3.1-flash-image",
|
||||
timeoutMs: 28000,
|
||||
},
|
||||
backup: {
|
||||
provider: "openrouter",
|
||||
model: "google/gemini-3.1-flash-image",
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
cooldownDurationMinutes: 10,
|
||||
enableSfwCheck: true,
|
||||
},
|
||||
kiosk: {
|
||||
enableAutoReset: true,
|
||||
idleTimeoutSeconds: 90,
|
||||
},
|
||||
labels: {
|
||||
welcomeBadge: {
|
||||
en: "SHANGHAI KARATE CLUB • EST. 2008",
|
||||
zh: "上海健志空手道馆 · 始于2008",
|
||||
},
|
||||
welcomeTitle: {
|
||||
en: "SPOOKY HAND 🥋🎃",
|
||||
zh: "万圣“空手”道馆 🥋🎃",
|
||||
},
|
||||
welcomeSubtitle: {
|
||||
en: "Channel Your Spooky Ki & Martial Pose!",
|
||||
zh: "凝聚万圣气合,定格武道英姿!",
|
||||
},
|
||||
welcomeDesc: {
|
||||
en: "Strike your fiercest martial arts pose! Turn your Halloween costume or karate gi into an epic Shanghai legend.",
|
||||
zh: "摆出你最帅气的武道姿势!无论身穿万圣盛装还是空手道服,一键变身魔都万圣武道传奇!",
|
||||
},
|
||||
welcomeCta: {
|
||||
en: "Enter The Haunted Dojo! ⚡",
|
||||
zh: "开启万圣武道之旅!⚡",
|
||||
},
|
||||
cameraInstruction: {
|
||||
en: "Strike a punch, block, or karate victory pose!",
|
||||
zh: "摆出冲拳、格挡或胜利姿态,展现你的气魄!",
|
||||
},
|
||||
cameraSnap: {
|
||||
en: "Snap Martial Shot",
|
||||
zh: "拍摄武道照",
|
||||
},
|
||||
cameraUpload: {
|
||||
en: "Upload Photo",
|
||||
zh: "上传照片",
|
||||
},
|
||||
confirmTitle: {
|
||||
en: "Review Your Martial Shot",
|
||||
zh: "确认你的英姿",
|
||||
},
|
||||
retakeButton: {
|
||||
en: "Retake",
|
||||
zh: "重拍",
|
||||
},
|
||||
confirmButton: {
|
||||
en: "Looks Epic! Choose Style",
|
||||
zh: "超帅气!选择风格",
|
||||
},
|
||||
styleSelectionTitle: {
|
||||
en: "Select Your Halloween Kata",
|
||||
zh: "选择你的万圣奥义风格",
|
||||
},
|
||||
styleSelectionSubtitle: {
|
||||
en: "Choose your legendary martial transformation",
|
||||
zh: "挑选专属的万圣节武道变身效果",
|
||||
},
|
||||
confirmStyleButton: {
|
||||
en: "Cast Martial Magic ⚡",
|
||||
zh: "施展武道魔法 ⚡",
|
||||
},
|
||||
backButton: {
|
||||
en: "Back",
|
||||
zh: "返回",
|
||||
},
|
||||
processingTitle: {
|
||||
en: "Gathering Martial Ki & Spooky Spirits...",
|
||||
zh: "正在凝聚万圣之气与道馆灵力...",
|
||||
},
|
||||
processingSubtitle: {
|
||||
en: "Summoning Huangpu mist, pumpkin embers & black belt magic...",
|
||||
zh: "融合黄浦江晨雾、南瓜火光与黑带武道魔法...",
|
||||
},
|
||||
resultTitle: {
|
||||
en: "Your Spooky Martial Portrait! 🥋🎃",
|
||||
zh: "你的万圣武道神采!🥋🎃",
|
||||
},
|
||||
scanQrLabel: {
|
||||
en: "Scan to save to your phone",
|
||||
zh: "扫码保存至手机相册",
|
||||
},
|
||||
downloadLabel: {
|
||||
en: "Save Portrait",
|
||||
zh: "保存高清照",
|
||||
},
|
||||
wechatSaveTip: {
|
||||
en: "In WeChat: Long press photo above to save directly!",
|
||||
zh: "微信提示:长按上方图片即可直接保存到相册!",
|
||||
},
|
||||
printLabel: {
|
||||
en: "Print Photo",
|
||||
zh: "打印照片",
|
||||
},
|
||||
startOverLabel: {
|
||||
en: "Train Again (Start Over)",
|
||||
zh: "再拍一张",
|
||||
},
|
||||
instagramShareLabel: {
|
||||
en: "Share Photo",
|
||||
zh: "分享照片",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getBoothConfig(): BoothConfig {
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), "config", "booth.config.json");
|
||||
const resolvedModels = resolveGenerationModels();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const fileContent = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
|
||||
const primaryModel =
|
||||
process.env.GENERATION_MODEL ||
|
||||
parsed.ai?.primary?.model === "gemini-2.5-flash" ||
|
||||
!parsed.ai?.primary?.model
|
||||
? resolvedModels.primary
|
||||
: parsed.ai.primary.model;
|
||||
|
||||
const backupModel =
|
||||
process.env.GENERATION_MODEL ||
|
||||
parsed.ai?.backup?.model === "google/gemini-2.5-flash" ||
|
||||
!parsed.ai?.backup?.model
|
||||
? resolvedModels.backup
|
||||
: parsed.ai.backup.model;
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...parsed,
|
||||
ai: {
|
||||
...defaultConfig.ai,
|
||||
...(parsed.ai || {}),
|
||||
primary: {
|
||||
...defaultConfig.ai.primary,
|
||||
...(parsed.ai?.primary || {}),
|
||||
model: primaryModel,
|
||||
},
|
||||
backup: {
|
||||
...defaultConfig.ai.backup,
|
||||
...(parsed.ai?.backup || {}),
|
||||
model: backupModel,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
...defaultConfig.labels,
|
||||
...(parsed.labels || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load booth.config.json, using fallback configuration:", error);
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
export function getBoothStyles(): BoothStyle[] {
|
||||
try {
|
||||
const stylesPath = path.join(process.cwd(), "config", "styles.json");
|
||||
if (fs.existsSync(stylesPath)) {
|
||||
const fileContent = fs.readFileSync(stylesPath, "utf-8");
|
||||
return JSON.parse(fileContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load styles.json, returning empty list:", error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { LocalizedString } from "@/types/booth";
|
||||
|
||||
export type Language = "en" | "zh";
|
||||
|
||||
interface I18nContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
toggleLanguage: () => void;
|
||||
t: (field: LocalizedString | string | undefined, fallback?: string) => string;
|
||||
isWeChat: boolean;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>("zh");
|
||||
const [isWeChat, setIsWeChat] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
// 1. Detect WeChat browser environment
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const inWeChat = ua.includes("micromessenger");
|
||||
setIsWeChat(inWeChat);
|
||||
|
||||
// 2. Check stored preference first
|
||||
const stored = localStorage.getItem("booth_lang") as Language | null;
|
||||
if (stored === "en" || stored === "zh") {
|
||||
setLanguageState(stored);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Fallback to system / browser language
|
||||
const browserLang = (navigator.language || "").toLowerCase();
|
||||
if (browserLang.startsWith("zh")) {
|
||||
setLanguageState("zh");
|
||||
} else {
|
||||
setLanguageState("en");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("booth_lang", lang);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = language === "zh" ? "en" : "zh";
|
||||
setLanguage(nextLang);
|
||||
};
|
||||
|
||||
const t = (field: LocalizedString | string | undefined, fallback: string = ""): string => {
|
||||
if (!field) return fallback;
|
||||
if (typeof field === "string") return field;
|
||||
return field[language] || field.en || fallback;
|
||||
};
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ language, setLanguage, toggleLanguage, t, isWeChat }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
return {
|
||||
language: "zh" as Language,
|
||||
setLanguage: () => {},
|
||||
toggleLanguage: () => {},
|
||||
t: (field: LocalizedString | string | undefined, fallback: string = "") => {
|
||||
if (!field) return fallback;
|
||||
if (typeof field === "string") return field;
|
||||
return field.zh || field.en || fallback;
|
||||
},
|
||||
isWeChat: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
interface DeviceUsage {
|
||||
count: number;
|
||||
firstUsedAt: number;
|
||||
}
|
||||
|
||||
// In-memory usage map (or keyed by hashed client token)
|
||||
const usageStore = new Map<string, DeviceUsage>();
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetMinutes: number;
|
||||
}
|
||||
|
||||
export function checkRateLimit(
|
||||
clientId: string,
|
||||
maxAllowed: number,
|
||||
cooldownMinutes: number
|
||||
): RateLimitResult {
|
||||
if (!maxAllowed || maxAllowed <= 0) {
|
||||
return { allowed: true, remaining: 999999, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000;
|
||||
const usage = usageStore.get(clientId);
|
||||
|
||||
if (!usage) {
|
||||
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
// Check if cooldown period has elapsed since first use
|
||||
if (now - usage.firstUsedAt > cooldownMs) {
|
||||
usageStore.delete(clientId);
|
||||
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, maxAllowed - usage.count);
|
||||
const remainingCooldownMs = cooldownMs - (now - usage.firstUsedAt);
|
||||
const resetMinutes = Math.ceil(remainingCooldownMs / (60 * 1000));
|
||||
|
||||
return {
|
||||
allowed: usage.count < maxAllowed,
|
||||
remaining,
|
||||
resetMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementRateLimit(clientId: string): void {
|
||||
const now = Date.now();
|
||||
const usage = usageStore.get(clientId);
|
||||
|
||||
if (!usage) {
|
||||
usageStore.set(clientId, { count: 1, firstUsedAt: now });
|
||||
} else {
|
||||
usage.count += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import crypto from "crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export interface ImageValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mimeType?: "image/jpeg" | "image/png" | "image/webp";
|
||||
extension?: "jpg" | "png" | "webp";
|
||||
sanitizedBase64?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known file signatures (magic bytes) for allowed image formats
|
||||
*/
|
||||
const MAGIC_SIGNATURES = {
|
||||
// JPEG: starts with FF D8 FF
|
||||
jpeg: [0xff, 0xd8, 0xff],
|
||||
// PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
||||
png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||
// WebP: starts with "RIFF" (52 49 46 46) and at offset 8 has "WEBP" (57 45 42 50)
|
||||
riff: [0x52, 0x49, 0x46, 0x46],
|
||||
webp: [0x57, 0x45, 0x42, 0x50],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dangerous executable & script signatures to explicitly detect and reject
|
||||
*/
|
||||
const DANGEROUS_SIGNATURES = [
|
||||
// Windows DOS/PE executable ('MZ' - 4D 5A)
|
||||
{ name: "Windows Executable (PE/DOS)", bytes: [0x4d, 0x5a] },
|
||||
// Linux ELF binary (7F 45 4C 46)
|
||||
{ name: "Linux ELF Binary", bytes: [0x7f, 0x45, 0x4c, 0x46] },
|
||||
// Mach-O binary (FE ED FA CE / FE ED FA CF / CE FA ED FE / CF FA ED FE)
|
||||
{ name: "Mach-O Binary (32-bit)", bytes: [0xfe, 0xed, 0xfa, 0xce] },
|
||||
{ name: "Mach-O Binary (64-bit)", bytes: [0xfe, 0xed, 0xfa, 0xcf] },
|
||||
{ name: "Mach-O Binary (rev 32)", bytes: [0xce, 0xfa, 0xed, 0xfe] },
|
||||
{ name: "Mach-O Binary (rev 64)", bytes: [0xcf, 0xfa, 0xed, 0xfe] },
|
||||
// Java class file (CA FE BA BE)
|
||||
{ name: "Java Class File", bytes: [0xca, 0xfe, 0xba, 0xbe] },
|
||||
// Shell script shebang ('#!' - 23 21)
|
||||
{ name: "Shell Script", bytes: [0x23, 0x21] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches bytes at specific offset
|
||||
*/
|
||||
function matchesBytes(buffer: Buffer, expected: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + expected.length) return false;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (buffer[offset + i] !== expected[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a base64 string or data URL:
|
||||
* 1. Checks max size limits
|
||||
* 2. Decodes binary buffer
|
||||
* 3. Scans for known executable/script signatures
|
||||
* 4. Verifies genuine image magic bytes (JPEG, PNG, WebP)
|
||||
* 5. Rejects scripts / HTML / SVG content
|
||||
*/
|
||||
export function validateAndSanitizeImage(
|
||||
input: string,
|
||||
maxSizeBytes: number = 10 * 1024 * 1024
|
||||
): ImageValidationResult {
|
||||
if (!input || typeof input !== "string") {
|
||||
return { valid: false, error: "Missing or invalid image payload" };
|
||||
}
|
||||
|
||||
// Check approximate string size (base64 string is ~4/3 larger than binary)
|
||||
// 10MB binary ≈ 13.5MB base64 string
|
||||
const maxStringLen = Math.ceil(maxSizeBytes * 1.4) + 1024;
|
||||
if (input.length > maxStringLen) {
|
||||
return { valid: false, error: `Image exceeds maximum allowed size (${Math.round(maxSizeBytes / (1024 * 1024))}MB)` };
|
||||
}
|
||||
|
||||
// Strip possible Data URL prefix
|
||||
const dataUrlMatch = input.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/);
|
||||
const rawBase64 = dataUrlMatch ? dataUrlMatch[2] : input.trim();
|
||||
|
||||
// Basic Base64 character validation (safe regex pattern)
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(rawBase64)) {
|
||||
return { valid: false, error: "Invalid base64 encoding" };
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(rawBase64, "base64");
|
||||
} catch {
|
||||
return { valid: false, error: "Failed to decode base64 payload" };
|
||||
}
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return { valid: false, error: "Empty image payload" };
|
||||
}
|
||||
|
||||
if (buffer.length > maxSizeBytes) {
|
||||
return { valid: false, error: `Image binary size exceeds ${Math.round(maxSizeBytes / (1024 * 1024))}MB limit` };
|
||||
}
|
||||
|
||||
// 1. Explicitly check for known executable or script signatures
|
||||
for (const sig of DANGEROUS_SIGNATURES) {
|
||||
if (matchesBytes(buffer, sig.bytes)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Executable or script payload detected (${sig.name}). Upload rejected.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for script/text content (e.g. <?php, <script, <svg, <html>)
|
||||
const headerPreview = buffer.slice(0, Math.min(buffer.length, 512)).toString("utf8").toLowerCase();
|
||||
const dangerousPatterns = [
|
||||
"<?php",
|
||||
"<% ",
|
||||
"<script",
|
||||
"<html",
|
||||
"<body",
|
||||
"<svg",
|
||||
"#!/",
|
||||
"eval(",
|
||||
"system(",
|
||||
"exec(",
|
||||
];
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (headerPreview.includes(pattern)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Script or HTML content detected in image payload. Upload rejected.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify legitimate image magic bytes
|
||||
let detectedType: "image/jpeg" | "image/png" | "image/webp" | null = null;
|
||||
let extension: "jpg" | "png" | "webp" = "jpg";
|
||||
|
||||
if (matchesBytes(buffer, MAGIC_SIGNATURES.jpeg)) {
|
||||
detectedType = "image/jpeg";
|
||||
extension = "jpg";
|
||||
} else if (matchesBytes(buffer, MAGIC_SIGNATURES.png)) {
|
||||
detectedType = "image/png";
|
||||
extension = "png";
|
||||
} else if (
|
||||
matchesBytes(buffer, MAGIC_SIGNATURES.riff, 0) &&
|
||||
matchesBytes(buffer, MAGIC_SIGNATURES.webp, 8)
|
||||
) {
|
||||
detectedType = "image/webp";
|
||||
extension = "webp";
|
||||
}
|
||||
|
||||
if (!detectedType) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Unsupported or invalid image format. Only authentic JPEG, PNG, or WebP images are permitted.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
mimeType: detectedType,
|
||||
extension,
|
||||
sanitizedBase64: `data:${detectedType};base64,${buffer.toString("base64")}`,
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes the client IP address from request headers
|
||||
*/
|
||||
export function getClientIp(req: NextRequest): string {
|
||||
const forwardedFor = req.headers.get("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
const firstIp = forwardedFor.split(",")[0].trim();
|
||||
if (firstIp) return firstIp;
|
||||
}
|
||||
|
||||
const realIp = req.headers.get("x-real-ip");
|
||||
if (realIp && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison using SHA-256 digests to prevent timing attacks
|
||||
*/
|
||||
export function constantTimeCompare(a: string, b: string): boolean {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
|
||||
const hashA = crypto.createHash("sha256").update(a).digest();
|
||||
const hashB = crypto.createHash("sha256").update(b).digest();
|
||||
|
||||
return crypto.timingSafeEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory brute-force protection for sensitive endpoints (e.g. portal login)
|
||||
*/
|
||||
interface AuthAttemptRecord {
|
||||
failedAttempts: number;
|
||||
lockedUntil: number;
|
||||
firstAttemptAt: number;
|
||||
}
|
||||
|
||||
const authAttemptsStore = new Map<string, AuthAttemptRecord>();
|
||||
|
||||
export function checkAuthRateLimit(
|
||||
ip: string,
|
||||
maxFailures: number = 5,
|
||||
lockoutMinutes: number = 15
|
||||
): { allowed: boolean; remainingAttempts: number; lockoutSeconds: number } {
|
||||
const now = Date.now();
|
||||
const record = authAttemptsStore.get(ip);
|
||||
|
||||
if (!record) {
|
||||
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
|
||||
}
|
||||
|
||||
// If currently locked out
|
||||
if (record.lockedUntil > now) {
|
||||
const lockoutSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
||||
return { allowed: false, remainingAttempts: 0, lockoutSeconds };
|
||||
}
|
||||
|
||||
// If lockout or window has expired (15 minutes), reset
|
||||
const windowMs = lockoutMinutes * 60 * 1000;
|
||||
if (now - record.firstAttemptAt > windowMs) {
|
||||
authAttemptsStore.delete(ip);
|
||||
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
|
||||
}
|
||||
|
||||
const remainingAttempts = Math.max(0, maxFailures - record.failedAttempts);
|
||||
return {
|
||||
allowed: record.failedAttempts < maxFailures,
|
||||
remainingAttempts,
|
||||
lockoutSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordAuthFailure(
|
||||
ip: string,
|
||||
maxFailures: number = 5,
|
||||
lockoutMinutes: number = 15
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const record = authAttemptsStore.get(ip);
|
||||
|
||||
if (!record) {
|
||||
authAttemptsStore.set(ip, {
|
||||
failedAttempts: 1,
|
||||
lockedUntil: 0,
|
||||
firstAttemptAt: now,
|
||||
});
|
||||
} else {
|
||||
record.failedAttempts += 1;
|
||||
if (record.failedAttempts >= maxFailures) {
|
||||
record.lockedUntil = now + lockoutMinutes * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recordAuthSuccess(ip: string): void {
|
||||
authAttemptsStore.delete(ip);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { validateAndSanitizeImage } from "@/lib/security";
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
timestamp: string; // ISO 8601 string
|
||||
dateStr: string; // YYYY-MM-DD for fast grouping
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
imageUrl?: string;
|
||||
imageFilename?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
period: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
totalAttempts: number;
|
||||
successCount: number;
|
||||
refusedCount: number;
|
||||
errorCount: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
dailyTrends: {
|
||||
date: string;
|
||||
total: number;
|
||||
success: number;
|
||||
refused: number;
|
||||
error: number;
|
||||
}[];
|
||||
styleBreakdown: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
providerBreakdown: {
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
images: GenerationRecord[];
|
||||
errorLogs: GenerationRecord[];
|
||||
}
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const IMAGES_DIR = path.join(DATA_DIR, "images");
|
||||
const RECORDS_FILE = path.join(DATA_DIR, "records.json");
|
||||
const BACKUP_FILE = path.join(DATA_DIR, "records.json.bak");
|
||||
|
||||
function ensureDirectories() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(IMAGES_DIR)) {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECORDS_FILE)) {
|
||||
fs.writeFileSync(RECORDS_FILE, JSON.stringify([], null, 2), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutex / Write Queue to prevent concurrent write collisions
|
||||
*/
|
||||
let writeQueuePromise: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite(task: () => Promise<void> | void): Promise<void> {
|
||||
const next = writeQueuePromise.then(() => task()).catch((err) => {
|
||||
console.error("[Storage Mutex] Write task error:", err);
|
||||
});
|
||||
writeQueuePromise = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all records from disk safely with automatic backup recovery
|
||||
*/
|
||||
export function getAllRecords(): GenerationRecord[] {
|
||||
ensureDirectories();
|
||||
try {
|
||||
const raw = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (!raw.trim()) {
|
||||
return recoverFromBackup();
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn("[Storage] records.json is not an array, attempting backup recovery...");
|
||||
return recoverFromBackup();
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error("[Storage] JSON parse error in records.json, attempting backup recovery:", err);
|
||||
return recoverFromBackup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover from backup file if primary file was corrupted
|
||||
*/
|
||||
function recoverFromBackup(): GenerationRecord[] {
|
||||
try {
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
const backupRaw = fs.readFileSync(BACKUP_FILE, "utf8");
|
||||
const parsed = JSON.parse(backupRaw);
|
||||
if (Array.isArray(parsed)) {
|
||||
console.warn("[Storage] Successfully recovered records from records.json.bak!");
|
||||
// Re-write valid records back to records.json
|
||||
fs.writeFileSync(RECORDS_FILE, backupRaw, "utf8");
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.error("[Storage] Backup recovery failed:", backupErr);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist records to disk atomically with fsync and backup rotation
|
||||
*/
|
||||
async function saveAllRecordsAtomic(records: GenerationRecord[]): Promise<void> {
|
||||
await queueWrite(() => {
|
||||
ensureDirectories();
|
||||
|
||||
const serialized = JSON.stringify(records, null, 2);
|
||||
// Safety check: Never write invalid or empty string if we had records
|
||||
if (!serialized || serialized.length < 2) {
|
||||
console.error("[Storage] Refusing to write invalid empty JSON payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Maintain backup copy of current good records file before replacing
|
||||
if (fs.existsSync(RECORDS_FILE)) {
|
||||
try {
|
||||
const currentContent = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (currentContent.trim().length > 2) {
|
||||
fs.writeFileSync(BACKUP_FILE, currentContent, "utf8");
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.warn("[Storage] Failed to update backup copy:", backupErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write to unique temporary file
|
||||
const tempFile = path.join(
|
||||
DATA_DIR,
|
||||
`records.${Date.now()}_${Math.random().toString(36).substring(2, 8)}.tmp`
|
||||
);
|
||||
|
||||
const fd = fs.openSync(tempFile, "w");
|
||||
fs.writeSync(fd, serialized, 0, "utf8");
|
||||
// Ensure data is physically flushed to disk platter/SSD before rename
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
|
||||
// 3. Atomic rename replaces the destination file instantaneously
|
||||
fs.renameSync(tempFile, RECORDS_FILE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an image file from base64 or URL buffer safely with binary validation
|
||||
*/
|
||||
export async function persistImageFile(
|
||||
id: string,
|
||||
imageInput: string
|
||||
): Promise<{ imageUrl: string; filename: string }> {
|
||||
ensureDirectories();
|
||||
|
||||
// Sanitize id to alphanumeric characters, hyphens, and underscores only
|
||||
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!safeId) {
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// If it's a data URL or raw base64
|
||||
if (imageInput.startsWith("data:image/") || imageInput.startsWith("data:application/")) {
|
||||
const validation = validateAndSanitizeImage(imageInput, 15 * 1024 * 1024);
|
||||
if (validation.valid && validation.extension) {
|
||||
const ext = validation.extension; // Strictly "jpg" | "png" | "webp"
|
||||
const filename = `${safeId}.${ext}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
|
||||
const base64Data = validation.sanitizedBase64!.split(",")[1];
|
||||
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a remote URL
|
||||
if (imageInput.startsWith("http://") || imageInput.startsWith("https://")) {
|
||||
try {
|
||||
const parsedUrl = new URL(imageInput);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
// Block SSRF loopback / private addresses
|
||||
const isPrivateOrLoopback =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||
hostname.startsWith("169.254.");
|
||||
|
||||
if (isPrivateOrLoopback) {
|
||||
console.warn(`[Storage SSRF Blocked] Attempted fetch to private/local address: ${hostname}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
const res = await fetch(imageInput);
|
||||
if (res.ok) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
// Validate downloaded buffer via data URL representation
|
||||
const validation = validateAndSanitizeImage(
|
||||
`data:image/png;base64,${buffer.toString("base64")}`,
|
||||
15 * 1024 * 1024
|
||||
);
|
||||
|
||||
if (validation.valid && validation.extension) {
|
||||
const filename = `${safeId}.${validation.extension}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Storage] Could not fetch remote image to cache locally, storing direct URL:", e);
|
||||
}
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// Local sample asset
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a generation event (both successes, safety refusals, and server errors)
|
||||
*/
|
||||
export async function logGenerationEvent(data: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
rawImageUrl?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}): Promise<GenerationRecord> {
|
||||
const now = new Date();
|
||||
const id = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
let imageUrl = data.rawImageUrl;
|
||||
let imageFilename = "";
|
||||
|
||||
if (data.status === "success" && data.rawImageUrl) {
|
||||
const persisted = await persistImageFile(id, data.rawImageUrl);
|
||||
imageUrl = persisted.imageUrl;
|
||||
imageFilename = persisted.filename;
|
||||
}
|
||||
|
||||
const record: GenerationRecord = {
|
||||
id,
|
||||
timestamp: now.toISOString(),
|
||||
dateStr,
|
||||
styleId: data.styleId,
|
||||
styleName: data.styleName,
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
durationMs: data.durationMs,
|
||||
status: data.status,
|
||||
imageUrl,
|
||||
imageFilename,
|
||||
errorType: data.errorType,
|
||||
errorMessage: data.errorMessage,
|
||||
refusalReason: data.refusalReason,
|
||||
clientId: data.clientId,
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = getAllRecords();
|
||||
existing.unshift(record); // Prepend so newest is first
|
||||
await saveAllRecordsAtomic(existing);
|
||||
} catch (err) {
|
||||
console.error("[Storage] Failed to append record:", err);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query statistics with date filtering
|
||||
*/
|
||||
export function getGenerationStats(query: {
|
||||
period?: "day" | "week" | "month" | "all" | "custom";
|
||||
fromDate?: string; // YYYY-MM-DD
|
||||
toDate?: string; // YYYY-MM-DD
|
||||
}): StatsSummary {
|
||||
const records = getAllRecords();
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
|
||||
const period = query.period || "week";
|
||||
let filtered = records;
|
||||
|
||||
let computedFrom = query.fromDate;
|
||||
let computedTo = query.toDate;
|
||||
|
||||
if (period === "day") {
|
||||
computedFrom = todayStr;
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr === todayStr);
|
||||
} else if (period === "week") {
|
||||
const sevenDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = sevenDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "month") {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "custom") {
|
||||
if (query.fromDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr >= query.fromDate!);
|
||||
}
|
||||
if (query.toDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr <= query.toDate!);
|
||||
}
|
||||
}
|
||||
|
||||
const totalAttempts = filtered.length;
|
||||
const successCount = filtered.filter((r) => r.status === "success").length;
|
||||
const refusedCount = filtered.filter((r) => r.status === "refused").length;
|
||||
const errorCount = filtered.filter((r) => r.status === "error").length;
|
||||
|
||||
const successRate = totalAttempts > 0 ? Math.round((successCount / totalAttempts) * 1000) / 10 : 0;
|
||||
|
||||
const successfulRecords = filtered.filter((r) => r.status === "success");
|
||||
const totalDuration = successfulRecords.reduce((sum, r) => sum + (r.durationMs || 0), 0);
|
||||
const avgDurationMs = successfulRecords.length > 0 ? Math.round(totalDuration / successfulRecords.length) : 0;
|
||||
|
||||
// Daily Trends aggregation
|
||||
const dailyMap = new Map<string, { total: number; success: number; refused: number; error: number }>();
|
||||
|
||||
// If week or day or month, pre-populate all dates in range so charts are continuous
|
||||
if (computedFrom && computedTo) {
|
||||
const start = new Date(computedFrom);
|
||||
const end = new Date(computedTo);
|
||||
const cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const d = cur.toISOString().slice(0, 10);
|
||||
dailyMap.set(d, { total: 0, success: 0, refused: 0, error: 0 });
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of filtered) {
|
||||
const entry = dailyMap.get(r.dateStr) || { total: 0, success: 0, refused: 0, error: 0 };
|
||||
entry.total += 1;
|
||||
if (r.status === "success") entry.success += 1;
|
||||
else if (r.status === "refused") entry.refused += 1;
|
||||
else if (r.status === "error") entry.error += 1;
|
||||
dailyMap.set(r.dateStr, entry);
|
||||
}
|
||||
|
||||
const dailyTrends = Array.from(dailyMap.entries())
|
||||
.map(([date, counts]) => ({ date, ...counts }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Style Breakdown
|
||||
const styleCountMap = new Map<string, { name: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
if (r.styleId) {
|
||||
const existing = styleCountMap.get(r.styleId) || { name: r.styleName || r.styleId, count: 0 };
|
||||
existing.count += 1;
|
||||
styleCountMap.set(r.styleId, existing);
|
||||
}
|
||||
}
|
||||
const styleBreakdown = Array.from(styleCountMap.entries())
|
||||
.map(([styleId, { name, count }]) => ({
|
||||
styleId,
|
||||
styleName: name,
|
||||
count,
|
||||
percentage: totalAttempts > 0 ? Math.round((count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Provider Breakdown
|
||||
const providerCountMap = new Map<string, { provider: string; model: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
const key = `${r.provider}::${r.model || "default"}`;
|
||||
const existing = providerCountMap.get(key) || { provider: r.provider, model: r.model || "default", count: 0 };
|
||||
existing.count += 1;
|
||||
providerCountMap.set(key, existing);
|
||||
}
|
||||
const providerBreakdown = Array.from(providerCountMap.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
percentage: totalAttempts > 0 ? Math.round((item.count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Filtered Images Gallery (success only)
|
||||
const images = filtered.filter((r) => r.status === "success" && r.imageUrl);
|
||||
|
||||
// Filtered Error & Refusal Logs
|
||||
const errorLogs = filtered.filter((r) => r.status !== "success");
|
||||
|
||||
return {
|
||||
period,
|
||||
fromDate: computedFrom,
|
||||
toDate: computedTo,
|
||||
totalAttempts,
|
||||
successCount,
|
||||
refusedCount,
|
||||
errorCount,
|
||||
successRate,
|
||||
avgDurationMs,
|
||||
dailyTrends,
|
||||
styleBreakdown,
|
||||
providerBreakdown,
|
||||
images,
|
||||
errorLogs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import io
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def watermark_portrait(image_path_or_buffer):
|
||||
"""
|
||||
Overlays:
|
||||
1. Shanghai Karate Club transparent logo in top-left corner
|
||||
2. WeChat-scannable school QR code with clean high-contrast backing in bottom-right corner
|
||||
3. Bottom branding text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
"""
|
||||
if isinstance(image_path_or_buffer, str):
|
||||
base = Image.open(image_path_or_buffer).convert("RGBA")
|
||||
else:
|
||||
base = Image.open(io.BytesIO(image_path_or_buffer)).convert("RGBA")
|
||||
|
||||
bw, bh = base.size
|
||||
|
||||
# 1. School Logo (Top-Left)
|
||||
logo_path = os.path.join(process_cwd(), "public", "assets", "brand", "shanghai_karate_logo.png")
|
||||
if os.path.exists(logo_path):
|
||||
logo = Image.open(logo_path).convert("RGBA")
|
||||
target_logo_w = int(bw * 0.28)
|
||||
target_logo_h = int(logo.height * (target_logo_w / logo.width))
|
||||
logo_resized = logo.resize((target_logo_w, target_logo_h), Image.Resampling.LANCZOS)
|
||||
margin = int(bw * 0.035)
|
||||
base.paste(logo_resized, (margin, margin), logo_resized)
|
||||
else:
|
||||
margin = int(bw * 0.035)
|
||||
|
||||
# 2. QR Code (Bottom-Right)
|
||||
qr_path = os.path.join(process_cwd(), "public", "assets", "brand", "dojo_qr.png")
|
||||
if os.path.exists(qr_path):
|
||||
qr = Image.open(qr_path).convert("RGBA")
|
||||
target_qr_w = int(bw * 0.17)
|
||||
qr_resized = qr.resize((target_qr_w, target_qr_w), Image.Resampling.LANCZOS)
|
||||
qr_pad = 5
|
||||
# Clean white backing ensures WeChat scans perfectly regardless of photo background
|
||||
qr_bg = Image.new("RGBA", (target_qr_w + qr_pad * 2, target_qr_w + qr_pad * 2), (255, 255, 255, 250))
|
||||
qr_pos_x = bw - (target_qr_w + qr_pad * 2) - margin
|
||||
qr_pos_y = bh - (target_qr_w + qr_pad * 2) - int(margin * 1.5)
|
||||
|
||||
base.paste(qr_bg, (qr_pos_x, qr_pos_y), qr_bg)
|
||||
base.paste(qr_resized, (qr_pos_x + qr_pad, qr_pos_y + qr_pad), qr_resized)
|
||||
|
||||
# 3. Bottom Text
|
||||
draw = ImageDraw.Draw(base)
|
||||
font_path = os.path.join(process_cwd(), "public", "fonts", "ZCOOLKuaiLe.ttf")
|
||||
font_size = max(14, int(bw * 0.026))
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = "上海健志空手道馆 · Shanghai Karate Club · Est. 2008"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
th = bbox[3] - bbox[1]
|
||||
tx = margin
|
||||
ty = bh - int(margin * 1.2) - th
|
||||
|
||||
# Subtle drop shadow + crisp warm white text
|
||||
draw.text((tx + 1, ty + 1), text, font=font, fill=(0, 0, 0, 210))
|
||||
draw.text((tx, ty), text, font=font, fill=(255, 240, 200, 255))
|
||||
|
||||
out_bytes = io.BytesIO()
|
||||
base.convert("RGB").save(out_bytes, format="JPEG", quality=94)
|
||||
return out_bytes.getvalue()
|
||||
|
||||
def process_cwd():
|
||||
return r"C:\Users\user\Desktop\katare_helloween"
|
||||
@@ -0,0 +1,35 @@
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Executes the Python watermark processor on an image file on disk.
|
||||
* Applies:
|
||||
* 1. School logo without background in top-left corner
|
||||
* 2. Scannable official WeChat QR code in bottom-right corner
|
||||
* 3. Bottom text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
*/
|
||||
export async function applyWatermarkToFile(filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "watermark_processor.py");
|
||||
const py = spawn("python", [scriptPath, filePath, filePath]);
|
||||
|
||||
py.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.warn(`[Watermark] Python watermark process exited with code ${code}`);
|
||||
resolve(); // Graceful fallback
|
||||
}
|
||||
});
|
||||
|
||||
py.on("error", (err) => {
|
||||
console.warn("[Watermark] Failed to spawn watermark process:", err);
|
||||
resolve(); // Graceful fallback
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"@": path.resolve(__dirname),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10mb",
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(self), microphone=()",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "1; mode=block",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "web_booth",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.43.0",
|
||||
"next": "^14.2.24",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" fill="none">
|
||||
<path fill="#FFFFFF" d="M38.8 68.2c-15.6 0-26.6-11.4-26.6-28.2 0-16.7 11-28.2 26.6-28.2 12.8 0 21.6 7.6 24.5 19.3h-12.7c-2-6-6-9.5-11.8-9.5-8.5 0-13.8 7.3-13.8 18.4 0 11.2 5.3 18.4 13.8 18.4 6 0 10.1-3.6 12-9.7h12.5c-2.9 11.8-11.7 19.5-24.5 19.5zM91.8 67.2l-3.2-11.7H73.2l-3.2 11.7H58.6l16.8-54.4h14.8l16.8 54.4H91.8zm-11.4-40.4L76 45.7h8.8l-4.4-18.9zM151.7 67.2V27.4l-11.4 39.8h-7.6l-11.4-39.8v39.8h-11.6V12.8h17.2l9.6 34 9.6-34h17.2v54.4h-12zM178.6 67.2V12.8h21.4c11.5 0 18.7 6.4 18.7 17.2 0 10.7-7.2 17.2-18.7 17.2h-9.8v20h-11.6zm11.6-30h9c5.2 0 8.4-2.8 8.4-7.2s-3.2-7.2-8.4-7.2h-9v14.4zM228.6 67.2V12.8H261v10.3h-20.8v11.7h17.7v10.3h-17.7v11.8h21.3v10.3h-32.9zM271.8 67.2V12.8h22.2c10.8 0 17.6 5.8 17.6 15.6 0 7.3-3.9 12.4-10.4 14.5l12.4 24.3h-13.3l-10.9-22.3h-6v22.3h-11.6zm11.6-32.6h9.8c4.6 0 7.4-2.5 7.4-6.3 0-3.9-2.8-6.3-7.4-6.3h-9.8v12.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 725 KiB |
@@ -0,0 +1,168 @@
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0-c000 1.000000, 0000/00/00-00:00:00 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif="http://ns.adobe.com/exif/1.0/"
|
||||
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
|
||||
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
exif:ExifVersion="0231"
|
||||
tiff:Orientation="1"
|
||||
xmpMM:DocumentID="E428F26B0CB3DA2E002A7E956A5A6857"
|
||||
xmpMM:OriginalDocumentID="E428F26B0CB3DA2E002A7E956A5A6857"
|
||||
xmpMM:InstanceID="xmp.iid:dd6b9761-1411-3346-8811-b11a209b60b6"
|
||||
dc:format="image/jpeg"
|
||||
crs:Version="18.4"
|
||||
crs:ProcessVersion="15.4"
|
||||
crs:WhiteBalance="As Shot"
|
||||
crs:IncrementalTemperature="0"
|
||||
crs:IncrementalTint="0"
|
||||
crs:Exposure2012="0.00"
|
||||
crs:Contrast2012="0"
|
||||
crs:Highlights2012="0"
|
||||
crs:Shadows2012="0"
|
||||
crs:Whites2012="0"
|
||||
crs:Blacks2012="0"
|
||||
crs:Texture="0"
|
||||
crs:Clarity2012="0"
|
||||
crs:Dehaze="0"
|
||||
crs:Vibrance="0"
|
||||
crs:Saturation="0"
|
||||
crs:ParametricShadows="0"
|
||||
crs:ParametricDarks="0"
|
||||
crs:ParametricLights="0"
|
||||
crs:ParametricHighlights="0"
|
||||
crs:ParametricShadowSplit="25"
|
||||
crs:ParametricMidtoneSplit="50"
|
||||
crs:ParametricHighlightSplit="75"
|
||||
crs:Sharpness="0"
|
||||
crs:LuminanceSmoothing="0"
|
||||
crs:ColorNoiseReduction="0"
|
||||
crs:HueAdjustmentRed="0"
|
||||
crs:HueAdjustmentOrange="0"
|
||||
crs:HueAdjustmentYellow="0"
|
||||
crs:HueAdjustmentGreen="0"
|
||||
crs:HueAdjustmentAqua="0"
|
||||
crs:HueAdjustmentBlue="0"
|
||||
crs:HueAdjustmentPurple="0"
|
||||
crs:HueAdjustmentMagenta="0"
|
||||
crs:SaturationAdjustmentRed="0"
|
||||
crs:SaturationAdjustmentOrange="0"
|
||||
crs:SaturationAdjustmentYellow="0"
|
||||
crs:SaturationAdjustmentGreen="0"
|
||||
crs:SaturationAdjustmentAqua="0"
|
||||
crs:SaturationAdjustmentBlue="0"
|
||||
crs:SaturationAdjustmentPurple="0"
|
||||
crs:SaturationAdjustmentMagenta="0"
|
||||
crs:LuminanceAdjustmentRed="0"
|
||||
crs:LuminanceAdjustmentOrange="0"
|
||||
crs:LuminanceAdjustmentYellow="0"
|
||||
crs:LuminanceAdjustmentGreen="0"
|
||||
crs:LuminanceAdjustmentAqua="0"
|
||||
crs:LuminanceAdjustmentBlue="0"
|
||||
crs:LuminanceAdjustmentPurple="0"
|
||||
crs:LuminanceAdjustmentMagenta="0"
|
||||
crs:SplitToningShadowHue="0"
|
||||
crs:SplitToningShadowSaturation="0"
|
||||
crs:SplitToningHighlightHue="0"
|
||||
crs:SplitToningHighlightSaturation="0"
|
||||
crs:SplitToningBalance="0"
|
||||
crs:ColorGradeMidtoneHue="0"
|
||||
crs:ColorGradeMidtoneSat="0"
|
||||
crs:ColorGradeShadowLum="0"
|
||||
crs:ColorGradeMidtoneLum="0"
|
||||
crs:ColorGradeHighlightLum="0"
|
||||
crs:ColorGradeBlending="50"
|
||||
crs:ColorGradeGlobalHue="0"
|
||||
crs:ColorGradeGlobalSat="0"
|
||||
crs:ColorGradeGlobalLum="0"
|
||||
crs:AutoLateralCA="0"
|
||||
crs:LensProfileEnable="0"
|
||||
crs:LensManualDistortionAmount="0"
|
||||
crs:VignetteAmount="0"
|
||||
crs:DefringePurpleAmount="0"
|
||||
crs:DefringePurpleHueLo="30"
|
||||
crs:DefringePurpleHueHi="70"
|
||||
crs:DefringeGreenAmount="0"
|
||||
crs:DefringeGreenHueLo="40"
|
||||
crs:DefringeGreenHueHi="60"
|
||||
crs:PerspectiveUpright="0"
|
||||
crs:PerspectiveVertical="0"
|
||||
crs:PerspectiveHorizontal="0"
|
||||
crs:PerspectiveRotate="0.0"
|
||||
crs:PerspectiveAspect="0"
|
||||
crs:PerspectiveScale="100"
|
||||
crs:PerspectiveX="0.00"
|
||||
crs:PerspectiveY="0.00"
|
||||
crs:ReshapeAmount="0"
|
||||
crs:GrainAmount="0"
|
||||
crs:PostCropVignetteAmount="0"
|
||||
crs:ShadowTint="0"
|
||||
crs:RedHue="0"
|
||||
crs:RedSaturation="0"
|
||||
crs:GreenHue="0"
|
||||
crs:GreenSaturation="0"
|
||||
crs:BlueHue="0"
|
||||
crs:BlueSaturation="0"
|
||||
crs:HDREditMode="0"
|
||||
crs:ConvertToGrayscale="False"
|
||||
crs:OverrideLookVignette="False"
|
||||
crs:ToneCurveName2012="Linear"
|
||||
crs:CameraProfile="Embedded"
|
||||
crs:CameraProfileDigest="594D92DCABEEABABE349C650F31FA89C"
|
||||
crs:HasSettings="True"
|
||||
crs:HasCrop="False"
|
||||
crs:AlreadyApplied="False"
|
||||
photoshop:SidecarForExtension="JPG"
|
||||
photoshop:EmbeddedXMPDigest="00000000000000000000000000000000"
|
||||
xmp:MetadataDate="2026-10-27T23:42:48+08:00">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li
|
||||
stEvt:action="saved"
|
||||
stEvt:instanceID="xmp.iid:dd6b9761-1411-3346-8811-b11a209b60b6"
|
||||
stEvt:when="2026-10-27T23:42:48+08:00"
|
||||
stEvt:softwareAgent="Adobe Photoshop Camera Raw 18.4 (Windows)"
|
||||
stEvt:changed="/metadata"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
<crs:ToneCurvePV2012Red>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Red>
|
||||
<crs:ToneCurvePV2012Green>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Green>
|
||||
<crs:ToneCurvePV2012Blue>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Blue>
|
||||
<crs:PointColors>
|
||||
<rdf:Seq>
|
||||
<rdf:li>-1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:PointColors>
|
||||
<crs:ColorVariance>
|
||||
<rdf:Seq>
|
||||
<rdf:li>-50.000000</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ColorVariance>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
After Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,81 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 120" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="pumpkinGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffb703" />
|
||||
<stop offset="50%" stop-color="#fb8500" />
|
||||
<stop offset="100%" stop-color="#d90429" />
|
||||
</linearGradient>
|
||||
<linearGradient id="slimeGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a3e635" />
|
||||
<stop offset="100%" stop-color="#4ade80" />
|
||||
</linearGradient>
|
||||
<filter id="cartoonShadow" x="-20%" y="-20%" width="150%" height="150%">
|
||||
<feDropShadow dx="3" dy="4" stdDeviation="0" flood-color="#090314" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Left Cartoon Bat Wing -->
|
||||
<path d="M 45 62 C 22 36, 6 52, 0 46 C 4 68, 20 78, 45 74 Z" fill="#3b0764" stroke="#090314" stroke-width="2.5" />
|
||||
<!-- Right Cartoon Bat Wing -->
|
||||
<path d="M 95 62 C 118 36, 134 52, 140 46 C 136 68, 120 78, 95 74 Z" fill="#3b0764" stroke="#090314" stroke-width="2.5" />
|
||||
|
||||
<!-- Chunky Cartoon Jack-o'-Lantern -->
|
||||
<g transform="translate(70, 68)" filter="url(#cartoonShadow)">
|
||||
<!-- Base Pumpkin Lobes with thick cartoon stroke -->
|
||||
<ellipse cx="-23" cy="0" rx="14" ry="22" fill="url(#pumpkinGrad)" stroke="#090314" stroke-width="2.5" />
|
||||
<ellipse cx="23" cy="0" rx="14" ry="22" fill="url(#pumpkinGrad)" stroke="#090314" stroke-width="2.5" />
|
||||
<ellipse cx="-12" cy="0" rx="16" ry="25" fill="url(#pumpkinGrad)" stroke="#090314" stroke-width="2.5" />
|
||||
<ellipse cx="12" cy="0" rx="16" ry="25" fill="url(#pumpkinGrad)" stroke="#090314" stroke-width="2.5" />
|
||||
<ellipse cx="0" cy="0" rx="17" ry="26" fill="url(#pumpkinGrad)" stroke="#090314" stroke-width="2.5" />
|
||||
|
||||
<!-- Canadian Buffalo Plaid Toque -->
|
||||
<path d="M -19 -18 C -19 -34, 19 -34, 19 -18 Z" fill="#dc2626" stroke="#090314" stroke-width="2.5" />
|
||||
<rect x="-20" y="-18" width="40" height="7" rx="3" fill="#ffffff" stroke="#090314" stroke-width="2" />
|
||||
<!-- Mini Maple Leaf on Toque -->
|
||||
<path d="M 0 -17 L 1.5 -13.5 L 4 -14 L 2.5 -11.5 L 4 -9 L 1.5 -10 L 1.5 -8 L -1.5 -8 L -1.5 -10 L -4 -9 L -2.5 -11.5 L -4 -14 L -1.5 -13.5 Z" fill="#dc2626" />
|
||||
<!-- White Toque Pom-pom -->
|
||||
<circle cx="0" cy="-35" r="6" fill="#f8fafc" stroke="#090314" stroke-width="2" />
|
||||
|
||||
<!-- Expressive Cartoon Eyes & Tooth Smile -->
|
||||
<polygon points="-11,-4 -5,-4 -8,-12" fill="#fef08a" stroke="#090314" stroke-width="1.5" />
|
||||
<polygon points="11,-4 5,-4 8,-12" fill="#fef08a" stroke="#090314" stroke-width="1.5" />
|
||||
<path d="M -13 6 Q 0 19 13 6 Q 8 14 0 14 Q -8 14 -13 6 Z" fill="#fef08a" stroke="#090314" stroke-width="1.5" />
|
||||
<!-- Little tooth in smile -->
|
||||
<rect x="-3" y="6" width="3" height="4" fill="#090314" />
|
||||
</g>
|
||||
|
||||
<!-- Typography: Cartoon Chunky Comic Lettering -->
|
||||
<g transform="translate(148, 0)">
|
||||
<!-- YEG Tag Pill with Canadian Leaf -->
|
||||
<g filter="url(#cartoonShadow)">
|
||||
<rect x="0" y="20" width="76" height="22" rx="11" fill="#dc2626" stroke="#090314" stroke-width="2.5" />
|
||||
<text x="13" y="36" font-family="'Impact', 'Arial Black', sans-serif" font-weight="900" font-size="13" fill="#ffffff" letter-spacing="1">🍁 YEG</text>
|
||||
</g>
|
||||
|
||||
<!-- Subtitle: NORTHERN ENCHANTMENT -->
|
||||
<text x="86" y="36" font-family="'Arial Black', sans-serif" font-weight="900" font-size="11" fill="#4ade80" letter-spacing="1.5" stroke="#090314" stroke-width="0.8">
|
||||
ENCHANTED PORTAL
|
||||
</text>
|
||||
|
||||
<!-- SPOOKY BOOTH Main Title (Bubbly, Chunky Cartoon Pop Letters) -->
|
||||
<g filter="url(#cartoonShadow)">
|
||||
<!-- White shadow outline -->
|
||||
<text x="0" y="74" font-family="'Impact', 'Arial Black', sans-serif" font-weight="900" font-size="38" fill="#ffffff" stroke="#090314" stroke-width="4" stroke-linejoin="round" letter-spacing="2">
|
||||
SPOOKY
|
||||
</text>
|
||||
<text x="160" y="74" font-family="'Impact', 'Arial Black', sans-serif" font-weight="900" font-size="38" fill="#fb8500" stroke="#090314" stroke-width="4" stroke-linejoin="round" letter-spacing="2">
|
||||
BOOTH
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Dripping Slime drops on title -->
|
||||
<circle cx="12" cy="77" r="2.5" fill="#a3e635" />
|
||||
<circle cx="155" cy="77" r="2" fill="#a3e635" />
|
||||
<circle cx="288" cy="76" r="2.5" fill="#fb8500" />
|
||||
|
||||
<!-- Pure Magic Tagline: ZERO AI MENTION -->
|
||||
<text x="2" y="96" font-family="'Arial Black', sans-serif" font-weight="900" font-size="12" fill="#facc15" letter-spacing="2.5" stroke="#090314" stroke-width="0.8">
|
||||
HALLOWEEN PHOTO PORTAL 🎃
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 897 KiB |
@@ -0,0 +1,40 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">
|
||||
<defs>
|
||||
<radialGradient id="ghostGlow" cx="50%" cy="40%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="70%" stop-color="#e0e7ff" />
|
||||
<stop offset="100%" stop-color="#818cf8" />
|
||||
</radialGradient>
|
||||
<linearGradient id="auroraBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e1b4b" />
|
||||
<stop offset="60%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#064e3b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background Badge Circle -->
|
||||
<circle cx="50" cy="50" r="46" fill="url(#auroraBg)" stroke="#38bdf8" stroke-width="2" stroke-dasharray="2 2" />
|
||||
|
||||
<!-- Aurora streak in badge -->
|
||||
<path d="M 10 35 Q 50 20 90 40 Q 50 30 10 35 Z" fill="#4ade80" opacity="0.35" />
|
||||
|
||||
<!-- Ghost Body wearing retro hockey helmet / jersey collar -->
|
||||
<path d="M 50 22 C 36 22 30 35 30 52 C 30 65 24 72 26 78 C 28 82 34 76 38 78 C 42 80 46 75 50 78 C 54 75 58 80 62 78 C 66 76 72 82 74 78 C 76 72 70 65 70 52 C 70 35 64 22 50 22 Z" fill="url(#ghostGlow)" filter="drop-shadow(0 4px 6px rgba(0,0,0,0.4))" />
|
||||
|
||||
<!-- Retro Blue & Orange Hockey Stripe on ghost jersey -->
|
||||
<path d="M 31 54 Q 50 62 69 54" stroke="#0284c7" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
<path d="M 31 58 Q 50 66 69 58" stroke="#ea580c" stroke-width="2" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Cute ghost eyes and happy mouth -->
|
||||
<ellipse cx="44" cy="40" rx="3" ry="4" fill="#1e1b4b" />
|
||||
<ellipse cx="56" cy="40" rx="3" ry="4" fill="#1e1b4b" />
|
||||
<circle cx="43" cy="38" r="1" fill="#ffffff" />
|
||||
<circle cx="55" cy="38" r="1" fill="#ffffff" />
|
||||
<ellipse cx="50" cy="48" rx="2.5" ry="3.5" fill="#1e1b4b" />
|
||||
|
||||
<!-- Hockey Stick -->
|
||||
<path d="M 68 36 L 76 68 L 86 70" stroke="#f59e0b" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M 80 69 L 85 70" stroke="#ffffff" stroke-width="4" stroke-linecap="round" />
|
||||
|
||||
<!-- Glowing Ghost Puck -->
|
||||
<ellipse cx="80" cy="79" rx="5" ry="2.5" fill="#0f172a" stroke="#38bdf8" stroke-width="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 824 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">
|
||||
<defs>
|
||||
<radialGradient id="bgMullet" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#4c1d95" />
|
||||
<stop offset="100%" stop-color="#1e1035" />
|
||||
</radialGradient>
|
||||
<linearGradient id="neonGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ec4899" />
|
||||
<stop offset="100%" stop-color="#f59e0b" />
|
||||
</linearGradient>
|
||||
<linearGradient id="hairGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#451a03" />
|
||||
<stop offset="100%" stop-color="#1c1917" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Badge Background -->
|
||||
<circle cx="50" cy="50" r="46" fill="url(#bgMullet)" stroke="#f43f5e" stroke-width="2" />
|
||||
|
||||
<!-- 80s Grid Sun Rays / Retro glow -->
|
||||
<circle cx="50" cy="48" r="34" fill="none" stroke="url(#neonGlow)" stroke-width="1.5" stroke-dasharray="4,4" opacity="0.6" />
|
||||
|
||||
<!-- Flowing Mullet Hair in Back (Party in the back) -->
|
||||
<path d="M 28 42 C 20 54 22 76 26 84 C 32 86 38 78 40 70 C 44 80 56 80 60 70 C 62 78 68 86 74 84 C 78 76 80 54 72 42 Z" fill="url(#hairGrad)" />
|
||||
|
||||
<!-- Person Torso / Shoulders -->
|
||||
<path d="M 30 84 C 34 76 42 74 50 74 C 58 74 66 76 70 84 C 64 90 36 90 30 84 Z" fill="#3b82f6" />
|
||||
<!-- Retro Jacket Collar -->
|
||||
<polygon points="44,74 50,82 46,84" fill="#ec4899" />
|
||||
<polygon points="56,74 50,82 54,84" fill="#ec4899" />
|
||||
|
||||
<!-- Face / Neck -->
|
||||
<rect x="46" y="60" width="8" height="14" rx="3" fill="#fed7aa" />
|
||||
<ellipse cx="50" cy="48" rx="14" ry="16" fill="#fed7aa" />
|
||||
|
||||
<!-- Facial Features -->
|
||||
<!-- Happy Eyes -->
|
||||
<circle cx="45" cy="46" r="2" fill="#1e1b4b" />
|
||||
<circle cx="55" cy="46" r="2" fill="#1e1b4b" />
|
||||
<!-- Warm Broad Smile -->
|
||||
<path d="M 44 54 Q 50 60 56 54" stroke="#9a3412" stroke-width="2" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Mullet Hair Front & Top (Business in the front, voluminous feathered top) -->
|
||||
<path d="M 34 44 C 32 30 40 22 50 22 C 60 22 68 30 66 44 C 64 36 58 32 50 32 C 42 32 36 36 34 44 Z" fill="url(#hairGrad)" />
|
||||
<!-- Feathered Hair Volume & Bangs -->
|
||||
<path d="M 36 34 C 40 26 60 26 64 34 C 62 28 56 24 50 24 C 44 24 38 28 36 34 Z" fill="#78350f" opacity="0.8" />
|
||||
<!-- Sideburns -->
|
||||
<path d="M 34 42 L 36 50 L 39 46 Z" fill="url(#hairGrad)" />
|
||||
<path d="M 66 42 L 64 50 L 61 46 Z" fill="url(#hairGrad)" />
|
||||
|
||||
<!-- Retro Sparkles -->
|
||||
<polygon points="22,26 24,22 26,26 24,30" fill="#fde047" />
|
||||
<polygon points="78,32 79,29 80,32 79,35" fill="#38bdf8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 919 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">
|
||||
<defs>
|
||||
<radialGradient id="bgMuttart" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#3b0764" />
|
||||
<stop offset="80%" stop-color="#1e1b4b" />
|
||||
<stop offset="100%" stop-color="#0f172a" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pyramidGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.8" />
|
||||
<stop offset="50%" stop-color="#38bdf8" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#22c55e" stop-opacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Badge Background -->
|
||||
<circle cx="50" cy="50" r="46" fill="url(#bgMuttart)" stroke="#a855f7" stroke-width="2" />
|
||||
|
||||
<!-- Iconic Muttart Conservatory Glass Pyramid Silhouette in background -->
|
||||
<polygon points="50,22 15,78 85,78" fill="url(#pyramidGlow)" stroke="#c084fc" stroke-width="1.5" />
|
||||
<line x1="50" y1="22" x2="50" y2="78" stroke="#e9d5ff" stroke-width="1" opacity="0.6" />
|
||||
<line x1="26" y1="60" x2="74" y2="60" stroke="#e9d5ff" stroke-width="1" opacity="0.6" />
|
||||
<line x1="38" y1="42" x2="62" y2="42" stroke="#e9d5ff" stroke-width="1" opacity="0.6" />
|
||||
|
||||
<!-- Witch Hat -->
|
||||
<!-- Wide curved brim -->
|
||||
<ellipse cx="50" cy="56" rx="28" ry="7" fill="#18181b" />
|
||||
<!-- Hat cone -->
|
||||
<path d="M 33 55 Q 46 20 62 16 Q 52 38 67 55 Z" fill="#2e1065" />
|
||||
<!-- Purple Hat Band with gold buckle -->
|
||||
<path d="M 36 53 Q 50 56 64 53" stroke="#f59e0b" stroke-width="3" fill="none" />
|
||||
<rect x="47" y="52" width="6" height="5" fill="#fbbf24" rx="1" />
|
||||
|
||||
<!-- Bubbling Magic Potion Cauldron below -->
|
||||
<path d="M 35 68 C 35 62 65 62 65 68 C 72 74 70 86 64 88 C 58 89 42 89 36 88 C 30 86 28 74 35 68 Z" fill="#1e293b" stroke="#334155" stroke-width="2" />
|
||||
<!-- Glowing green potion surface -->
|
||||
<ellipse cx="50" cy="68" rx="14" ry="4" fill="#22c55e" />
|
||||
<!-- Bubbles floating up -->
|
||||
<circle cx="44" cy="62" r="2.5" fill="#4ade80" opacity="0.8" />
|
||||
<circle cx="53" cy="59" r="3.5" fill="#86efac" opacity="0.9" />
|
||||
<circle cx="58" cy="63" r="2" fill="#4ade80" opacity="0.7" />
|
||||
<!-- Magic sparkle star -->
|
||||
<path d="M 22 36 L 24 30 L 26 36 L 32 38 L 26 40 L 24 46 L 22 40 L 16 38 Z" fill="#fde047" opacity="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 868 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">
|
||||
<defs>
|
||||
<radialGradient id="bgSas" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#14532d" />
|
||||
<stop offset="100%" stop-color="#052e16" />
|
||||
</radialGradient>
|
||||
<pattern id="plaid" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<rect width="8" height="8" fill="#b91c1c" />
|
||||
<rect width="4" height="4" fill="#18181b" />
|
||||
<rect x="4" y="4" width="4" height="4" fill="#18181b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Badge Background -->
|
||||
<circle cx="50" cy="50" r="46" fill="url(#bgSas)" stroke="#22c55e" stroke-width="2" />
|
||||
|
||||
<!-- Pine Tree Silhouettes in background -->
|
||||
<polygon points="20,70 12,85 28,85" fill="#064e3b" />
|
||||
<polygon points="80,68 72,85 88,85" fill="#064e3b" />
|
||||
|
||||
<!-- Bigfoot / Sasquatch Furry Body (Warm Mocha Clay) -->
|
||||
<path d="M 50 26 C 32 26 28 42 28 66 C 28 80 34 86 50 86 C 66 86 72 80 72 66 C 72 42 68 26 50 26 Z" fill="#78350f" />
|
||||
|
||||
<!-- Sasquatch Fur Texture details / ears -->
|
||||
<circle cx="28" cy="42" r="5" fill="#78350f" />
|
||||
<circle cx="72" cy="42" r="5" fill="#78350f" />
|
||||
|
||||
<!-- Friendly Face Area -->
|
||||
<ellipse cx="50" cy="46" rx="14" ry="12" fill="#92400e" />
|
||||
<!-- Kind Eyes -->
|
||||
<circle cx="44" cy="44" r="2.5" fill="#1e1b4b" />
|
||||
<circle cx="56" cy="44" r="2.5" fill="#1e1b4b" />
|
||||
<circle cx="43" cy="43" r="0.8" fill="#ffffff" />
|
||||
<circle cx="55" cy="43" r="0.8" fill="#ffffff" />
|
||||
<!-- Friendly warm smile -->
|
||||
<path d="M 45 51 Q 50 56 55 51" stroke="#fef3c7" stroke-width="2" fill="none" stroke-linecap="round" />
|
||||
|
||||
<!-- Buffalo Plaid Canadian Toque -->
|
||||
<path d="M 33 30 C 33 16 67 16 67 30 Z" fill="url(#plaid)" />
|
||||
<rect x="31" y="28" width="38" height="6" rx="3" fill="#18181b" />
|
||||
<!-- Pom-pom on Toque -->
|
||||
<circle cx="50" cy="15" r="4" fill="#f87171" />
|
||||
|
||||
<!-- Little Pumpkin Candy Bucket in hand -->
|
||||
<ellipse cx="70" cy="74" rx="8" ry="7" fill="#ea580c" />
|
||||
<polygon points="68,72 72,72 70,69" fill="#fef08a" />
|
||||
<!-- Bucket handle -->
|
||||
<path d="M 64 72 Q 70 65 76 72" stroke="#18181b" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 767 KiB |
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100%" height="100%">
|
||||
<defs>
|
||||
<radialGradient id="bgVamp" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#4c0519" />
|
||||
<stop offset="60%" stop-color="#1e1b4b" />
|
||||
<stop offset="100%" stop-color="#09090b" />
|
||||
</radialGradient>
|
||||
<linearGradient id="moonGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fef08a" />
|
||||
<stop offset="100%" stop-color="#f97316" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Badge Background -->
|
||||
<circle cx="50" cy="50" r="46" fill="url(#bgVamp)" stroke="#e11d48" stroke-width="2" />
|
||||
|
||||
<!-- Harvest Moon behind bridge -->
|
||||
<circle cx="50" cy="38" r="20" fill="url(#moonGrad)" opacity="0.9" />
|
||||
|
||||
<!-- High Level Bridge Truss Silhouette -->
|
||||
<!-- Upper horizontal beam -->
|
||||
<rect x="15" y="44" width="70" height="3" fill="#18181b" />
|
||||
<!-- Lower beam -->
|
||||
<rect x="15" y="52" width="70" height="4" fill="#18181b" />
|
||||
<!-- Bridge pillars -->
|
||||
<rect x="25" y="52" width="5" height="32" fill="#18181b" />
|
||||
<rect x="48" y="52" width="5" height="32" fill="#18181b" />
|
||||
<rect x="71" y="52" width="5" height="32" fill="#18181b" />
|
||||
<!-- Truss diagonal criss-crosses -->
|
||||
<line x1="16" y1="44" x2="26" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
<line x1="26" y1="44" x2="36" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
<line x1="36" y1="44" x2="46" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
<line x1="46" y1="44" x2="56" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
<line x1="56" y1="44" x2="66" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
<line x1="66" y1="44" x2="76" y2="52" stroke="#18181b" stroke-width="1.5" />
|
||||
|
||||
<!-- Vampire Silhouette / Red Velvet Cape over Bridge -->
|
||||
<!-- Velvet cape -->
|
||||
<path d="M 50 36 C 42 36 34 46 32 60 C 38 65 44 58 50 63 C 56 58 62 65 68 60 C 66 46 58 36 50 36 Z" fill="#9f1239" />
|
||||
<!-- High vampire collar -->
|
||||
<polygon points="44,38 38,28 46,34" fill="#be123c" />
|
||||
<polygon points="56,38 62,28 54,34" fill="#be123c" />
|
||||
<!-- Friendly vampire face -->
|
||||
<circle cx="50" cy="35" r="7" fill="#fce7f3" />
|
||||
<!-- Vampire hair with widow's peak -->
|
||||
<path d="M 44 32 Q 50 36 56 32 C 57 28 43 28 44 32 Z" fill="#18181b" />
|
||||
<!-- Little cute eyes & smile with mini fangs -->
|
||||
<circle cx="48" cy="35" r="0.9" fill="#18181b" />
|
||||
<circle cx="52" cy="35" r="0.9" fill="#18181b" />
|
||||
<path d="M 48 38 Q 50 40 52 38" stroke="#be123c" stroke-width="0.8" fill="none" />
|
||||
<polygon points="48.5,38 49,39.5 49.5,38" fill="#ffffff" />
|
||||
<polygon points="50.5,38 51,39.5 51.5,38" fill="#ffffff" />
|
||||
|
||||
<!-- Little bat flying near moon -->
|
||||
<path d="M 68 25 Q 73 21 78 26 Q 73 24 68 25 Z" fill="#18181b" />
|
||||
<path d="M 68 25 Q 63 21 58 26 Q 63 24 68 25 Z" fill="#18181b" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" fill="none">
|
||||
<g filter="drop-shadow(0 8px 16px rgba(0,0,0,0.35))">
|
||||
<!-- Left Shoe -->
|
||||
<rect x="25" y="25" width="46" height="100" rx="23" fill="#3a1e0c" />
|
||||
<path d="M25 48 C25 35, 35 25, 48 25 C61 25, 71 35, 71 48 L71 62 L25 62 Z" fill="#8ca0ff" />
|
||||
<rect x="36" y="65" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="36" y="73" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="36" y="81" width="24" height="2" fill="#d9a566" />
|
||||
<circle cx="48" cy="105" r="9" fill="#201107" />
|
||||
|
||||
<!-- Right Shoe -->
|
||||
<rect x="89" y="25" width="46" height="100" rx="23" fill="#3a1e0c" />
|
||||
<path d="M89 48 C89 35, 99 25, 112 25 C125 25, 135 35, 135 48 L135 62 L89 62 Z" fill="#8ca0ff" />
|
||||
<rect x="100" y="65" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="100" y="73" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="100" y="81" width="24" height="2" fill="#d9a566" />
|
||||
<circle cx="112" cy="105" r="9" fill="#201107" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" fill="none">
|
||||
<g filter="drop-shadow(0 8px 16px rgba(0,0,0,0.35))">
|
||||
<!-- Left Shoe -->
|
||||
<rect x="25" y="25" width="46" height="100" rx="23" fill="#2d160a" />
|
||||
<path d="M25 48 C25 35, 35 25, 48 25 C61 25, 71 35, 71 48 L71 62 L25 62 Z" fill="#ff85a9" />
|
||||
<rect x="30" y="68" width="36" height="6" rx="3" fill="#ff85a9" opacity="0.8" />
|
||||
<circle cx="48" cy="105" r="9" fill="#180c05" />
|
||||
|
||||
<!-- Right Shoe -->
|
||||
<rect x="89" y="25" width="46" height="100" rx="23" fill="#2d160a" />
|
||||
<path d="M89 48 C89 35, 99 25, 112 25 C125 25, 135 35, 135 48 L135 62 L89 62 Z" fill="#ff85a9" />
|
||||
<rect x="94" y="68" width="36" height="6" rx="3" fill="#ff85a9" opacity="0.8" />
|
||||
<circle cx="112" cy="105" r="9" fill="#180c05" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" fill="none">
|
||||
<g filter="drop-shadow(0 8px 16px rgba(0,0,0,0.35))">
|
||||
<!-- Left Shoe -->
|
||||
<rect x="25" y="25" width="46" height="100" rx="23" fill="#1b1a19" />
|
||||
<path d="M25 48 C25 35, 35 25, 48 25 C61 25, 71 35, 71 48 L71 62 L25 62 Z" fill="#887e43" />
|
||||
<rect x="36" y="65" width="24" height="2" fill="#555" />
|
||||
<rect x="36" y="73" width="24" height="2" fill="#555" />
|
||||
<rect x="36" y="81" width="24" height="2" fill="#555" />
|
||||
<circle cx="48" cy="105" r="9" fill="#0d0d0d" />
|
||||
|
||||
<!-- Right Shoe -->
|
||||
<rect x="89" y="25" width="46" height="100" rx="23" fill="#1b1a19" />
|
||||
<path d="M89 48 C89 35, 99 25, 112 25 C125 25, 135 35, 135 48 L135 62 L89 62 Z" fill="#887e43" />
|
||||
<rect x="100" y="65" width="24" height="2" fill="#555" />
|
||||
<rect x="100" y="73" width="24" height="2" fill="#555" />
|
||||
<rect x="100" y="81" width="24" height="2" fill="#555" />
|
||||
<circle cx="112" cy="105" r="9" fill="#0d0d0d" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" fill="none">
|
||||
<g filter="drop-shadow(0 8px 16px rgba(0,0,0,0.35))">
|
||||
<!-- Left Shoe -->
|
||||
<rect x="25" y="25" width="46" height="100" rx="23" fill="#4a2511" />
|
||||
<path d="M25 48 C25 35, 35 25, 48 25 C61 25, 71 35, 71 48 L71 62 L25 62 Z" fill="#ff7da7" />
|
||||
<rect x="36" y="65" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="36" y="73" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="36" y="81" width="24" height="2" fill="#d9a566" />
|
||||
<circle cx="48" cy="105" r="9" fill="#301509" />
|
||||
|
||||
<!-- Right Shoe -->
|
||||
<rect x="89" y="25" width="46" height="100" rx="23" fill="#4a2511" />
|
||||
<path d="M89 48 C89 35, 99 25, 112 25 C125 25, 135 35, 135 48 L135 62 L89 62 Z" fill="#ff7da7" />
|
||||
<rect x="100" y="65" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="100" y="73" width="24" height="2" fill="#d9a566" />
|
||||
<rect x="100" y="81" width="24" height="2" fill="#d9a566" />
|
||||
<circle cx="112" cy="105" r="9" fill="#301509" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 370 KiB |
@@ -0,0 +1,168 @@
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 7.0-c000 1.000000, 0000/00/00-00:00:00 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif="http://ns.adobe.com/exif/1.0/"
|
||||
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
|
||||
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
exif:ExifVersion="0231"
|
||||
tiff:Orientation="1"
|
||||
xmpMM:DocumentID="1B6BDDFACB189FE2091DA704CF7C4C9C"
|
||||
xmpMM:OriginalDocumentID="1B6BDDFACB189FE2091DA704CF7C4C9C"
|
||||
xmpMM:InstanceID="xmp.iid:7693aff2-d966-a34a-98c7-2f8db676c7bf"
|
||||
dc:format="image/jpeg"
|
||||
crs:Version="18.4"
|
||||
crs:ProcessVersion="15.4"
|
||||
crs:WhiteBalance="As Shot"
|
||||
crs:IncrementalTemperature="0"
|
||||
crs:IncrementalTint="0"
|
||||
crs:Exposure2012="0.00"
|
||||
crs:Contrast2012="0"
|
||||
crs:Highlights2012="0"
|
||||
crs:Shadows2012="0"
|
||||
crs:Whites2012="0"
|
||||
crs:Blacks2012="0"
|
||||
crs:Texture="0"
|
||||
crs:Clarity2012="0"
|
||||
crs:Dehaze="0"
|
||||
crs:Vibrance="0"
|
||||
crs:Saturation="0"
|
||||
crs:ParametricShadows="0"
|
||||
crs:ParametricDarks="0"
|
||||
crs:ParametricLights="0"
|
||||
crs:ParametricHighlights="0"
|
||||
crs:ParametricShadowSplit="25"
|
||||
crs:ParametricMidtoneSplit="50"
|
||||
crs:ParametricHighlightSplit="75"
|
||||
crs:Sharpness="0"
|
||||
crs:LuminanceSmoothing="0"
|
||||
crs:ColorNoiseReduction="0"
|
||||
crs:HueAdjustmentRed="0"
|
||||
crs:HueAdjustmentOrange="0"
|
||||
crs:HueAdjustmentYellow="0"
|
||||
crs:HueAdjustmentGreen="0"
|
||||
crs:HueAdjustmentAqua="0"
|
||||
crs:HueAdjustmentBlue="0"
|
||||
crs:HueAdjustmentPurple="0"
|
||||
crs:HueAdjustmentMagenta="0"
|
||||
crs:SaturationAdjustmentRed="0"
|
||||
crs:SaturationAdjustmentOrange="0"
|
||||
crs:SaturationAdjustmentYellow="0"
|
||||
crs:SaturationAdjustmentGreen="0"
|
||||
crs:SaturationAdjustmentAqua="0"
|
||||
crs:SaturationAdjustmentBlue="0"
|
||||
crs:SaturationAdjustmentPurple="0"
|
||||
crs:SaturationAdjustmentMagenta="0"
|
||||
crs:LuminanceAdjustmentRed="0"
|
||||
crs:LuminanceAdjustmentOrange="0"
|
||||
crs:LuminanceAdjustmentYellow="0"
|
||||
crs:LuminanceAdjustmentGreen="0"
|
||||
crs:LuminanceAdjustmentAqua="0"
|
||||
crs:LuminanceAdjustmentBlue="0"
|
||||
crs:LuminanceAdjustmentPurple="0"
|
||||
crs:LuminanceAdjustmentMagenta="0"
|
||||
crs:SplitToningShadowHue="0"
|
||||
crs:SplitToningShadowSaturation="0"
|
||||
crs:SplitToningHighlightHue="0"
|
||||
crs:SplitToningHighlightSaturation="0"
|
||||
crs:SplitToningBalance="0"
|
||||
crs:ColorGradeMidtoneHue="0"
|
||||
crs:ColorGradeMidtoneSat="0"
|
||||
crs:ColorGradeShadowLum="0"
|
||||
crs:ColorGradeMidtoneLum="0"
|
||||
crs:ColorGradeHighlightLum="0"
|
||||
crs:ColorGradeBlending="50"
|
||||
crs:ColorGradeGlobalHue="0"
|
||||
crs:ColorGradeGlobalSat="0"
|
||||
crs:ColorGradeGlobalLum="0"
|
||||
crs:AutoLateralCA="0"
|
||||
crs:LensProfileEnable="0"
|
||||
crs:LensManualDistortionAmount="0"
|
||||
crs:VignetteAmount="0"
|
||||
crs:DefringePurpleAmount="0"
|
||||
crs:DefringePurpleHueLo="30"
|
||||
crs:DefringePurpleHueHi="70"
|
||||
crs:DefringeGreenAmount="0"
|
||||
crs:DefringeGreenHueLo="40"
|
||||
crs:DefringeGreenHueHi="60"
|
||||
crs:PerspectiveUpright="0"
|
||||
crs:PerspectiveVertical="0"
|
||||
crs:PerspectiveHorizontal="0"
|
||||
crs:PerspectiveRotate="0.0"
|
||||
crs:PerspectiveAspect="0"
|
||||
crs:PerspectiveScale="100"
|
||||
crs:PerspectiveX="0.00"
|
||||
crs:PerspectiveY="0.00"
|
||||
crs:ReshapeAmount="0"
|
||||
crs:GrainAmount="0"
|
||||
crs:PostCropVignetteAmount="0"
|
||||
crs:ShadowTint="0"
|
||||
crs:RedHue="0"
|
||||
crs:RedSaturation="0"
|
||||
crs:GreenHue="0"
|
||||
crs:GreenSaturation="0"
|
||||
crs:BlueHue="0"
|
||||
crs:BlueSaturation="0"
|
||||
crs:HDREditMode="0"
|
||||
crs:ConvertToGrayscale="False"
|
||||
crs:OverrideLookVignette="False"
|
||||
crs:ToneCurveName2012="Linear"
|
||||
crs:CameraProfile="Embedded"
|
||||
crs:CameraProfileDigest="594D92DCABEEABABE349C650F31FA89C"
|
||||
crs:HasSettings="True"
|
||||
crs:HasCrop="False"
|
||||
crs:AlreadyApplied="False"
|
||||
photoshop:SidecarForExtension="JPG"
|
||||
photoshop:EmbeddedXMPDigest="00000000000000000000000000000000"
|
||||
xmp:MetadataDate="2026-10-28T00:21:52+08:00">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li
|
||||
stEvt:action="saved"
|
||||
stEvt:instanceID="xmp.iid:7693aff2-d966-a34a-98c7-2f8db676c7bf"
|
||||
stEvt:when="2026-10-28T00:21:52+08:00"
|
||||
stEvt:softwareAgent="Adobe Photoshop Camera Raw 18.4 (Windows)"
|
||||
stEvt:changed="/metadata"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
<crs:ToneCurvePV2012Red>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Red>
|
||||
<crs:ToneCurvePV2012Green>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Green>
|
||||
<crs:ToneCurvePV2012Blue>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012Blue>
|
||||
<crs:PointColors>
|
||||
<rdf:Seq>
|
||||
<rdf:li>-1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000, -1.000000</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:PointColors>
|
||||
<crs:ColorVariance>
|
||||
<rdf:Seq>
|
||||
<rdf:li>-50.000000</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ColorVariance>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 761 KiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,8 @@
|
||||
import requests
|
||||
|
||||
for port in [3000, 3001]:
|
||||
try:
|
||||
r = requests.get(f"http://localhost:{port}/api/config", timeout=2)
|
||||
print(f"Port {port} /api/config: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Port {port}: failed ({e})")
|
||||
@@ -0,0 +1,8 @@
|
||||
import requests
|
||||
|
||||
for port in [3000, 3001, 34821]:
|
||||
try:
|
||||
r = requests.get(f"http://localhost:{port}", timeout=2)
|
||||
print(f"Port {port}: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Port {port}: failed ({e})")
|
||||
@@ -0,0 +1,46 @@
|
||||
import base64
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
def test_gen(style_id, output_path):
|
||||
with open('public/test_skeleton_kid.jpg', 'rb') as f:
|
||||
img_bytes = f.read()
|
||||
b64 = 'data:image/jpeg;base64,' + base64.b64encode(img_bytes).decode('utf-8')
|
||||
|
||||
payload = {
|
||||
'imageBase64': b64,
|
||||
'styleId': style_id,
|
||||
'clientId': 'dev_test_batch'
|
||||
}
|
||||
|
||||
print(f"Calling http://localhost:3001/api/generate for {style_id}...")
|
||||
try:
|
||||
res = requests.post('http://localhost:3001/api/generate', json=payload, timeout=90)
|
||||
print(f"Status: {res.status_code}")
|
||||
data = res.json()
|
||||
if res.status_code == 200 and 'imageUrl' in data:
|
||||
img_url = data['imageUrl']
|
||||
print(f"Success! Provider: {data.get('providerInfo')}")
|
||||
if img_url.startswith('data:'):
|
||||
header, b64data = img_url.split(',', 1)
|
||||
with open(output_path, 'wb') as out_f:
|
||||
out_f.write(base64.b64decode(b64data))
|
||||
else:
|
||||
full_url = 'http://localhost:3001' + img_url
|
||||
img_res = requests.get(full_url)
|
||||
with open(output_path, 'wb') as out_f:
|
||||
out_f.write(img_res.content)
|
||||
print(f"Saved to {output_path}")
|
||||
return True
|
||||
else:
|
||||
print("Error response:", data)
|
||||
return False
|
||||
except Exception as e:
|
||||
print("Exception:", e)
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
style = sys.argv[1]
|
||||
out = sys.argv[2]
|
||||
test_gen(style, out)
|
||||