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

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

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { AlertTriangle, Camera } from "lucide-react";
import { KioskFrame } from "@/components/KioskFrame";
import { StandbyScreen } from "@/components/screens/StandbyScreen";
import { CameraScreen } from "@/components/screens/CameraScreen";
import { ConfirmScreen } from "@/components/screens/ConfirmScreen";
import { StyleScreen } from "@/components/screens/StyleScreen";
import { LoadingScreen } from "@/components/screens/LoadingScreen";
import { ResultScreen } from "@/components/screens/ResultScreen";
import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen";
import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth";
import { I18nProvider, useI18n } from "@/lib/i18n";
function PhotoBoothApp() {
const [config, setConfig] = useState<BoothConfig | null>(null);
const [styles, setStyles] = useState<BoothStyle[]>([]);
const [screen, setScreen] = useState<ScreenState>("standby");
const [capturedPhoto, setCapturedPhoto] = useState<string | null>(null);
const [selectedStyle, setSelectedStyle] = useState<BoothStyle | null>(null);
const [resultImage, setResultImage] = useState<string | null>(null);
const [providerInfo, setProviderInfo] = useState<any>(null);
const [rateLimitResetMinutes, setRateLimitResetMinutes] = useState<number>(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
const [clientId, setClientId] = useState<string>("");
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const { t } = useI18n();
useEffect(() => {
let storedId = localStorage.getItem("booth_client_id");
if (!storedId) {
storedId = "dev_" + Math.random().toString(36).substring(2, 12);
localStorage.setItem("booth_client_id", storedId);
}
setClientId(storedId);
fetch("/api/config")
.then((res) => res.json())
.then((data) => {
if (data.styles) {
setStyles(data.styles);
}
setConfig(data);
// URL parameter support for direct previewing, e.g. /?preview=result
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const previewScreen = params.get("preview") as ScreenState | null;
if (previewScreen === "result") {
const previewImg = params.get("image") || "/assets/styles/jack_o_lantern_sensei.jpg";
setResultImage(previewImg);
setScreen("result");
}
}
})
.catch((err) => console.error("Failed to load booth config:", err));
}, []);
const resetIdleTimer = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
if (!config?.kiosk?.enableAutoReset || screen === "standby") {
return;
}
const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000;
idleTimerRef.current = setTimeout(() => {
console.log("[Kiosk] Idle timeout reached. Resetting to standby...");
setScreen("standby");
setCapturedPhoto(null);
setSelectedStyle(null);
setResultImage(null);
setErrorMessage(null);
setSafetyWarning(null);
}, timeoutMs);
}, [config, screen]);
useEffect(() => {
const handleActivity = () => resetIdleTimer();
window.addEventListener("pointerdown", handleActivity);
window.addEventListener("keydown", handleActivity);
resetIdleTimer();
return () => {
window.removeEventListener("pointerdown", handleActivity);
window.removeEventListener("keydown", handleActivity);
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
};
}, [resetIdleTimer]);
const handleStart = () => {
setErrorMessage(null);
setSafetyWarning(null);
setScreen("camera");
};
const handlePhotoCaptured = (photoBase64: string) => {
setCapturedPhoto(photoBase64);
setScreen("confirm");
};
const handleRetake = () => {
setCapturedPhoto(null);
setScreen("camera");
};
const handleConfirmPhoto = () => {
setScreen("style");
};
const handleStyleSelected = async (style: BoothStyle) => {
setSelectedStyle(style);
setScreen("processing");
setErrorMessage(null);
setSafetyWarning(null);
try {
const response = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-client-id": clientId,
},
body: JSON.stringify({
imageBase64: capturedPhoto,
styleId: style.id,
}),
});
const data = await response.json();
if (response.status === 429) {
setRateLimitResetMinutes(data.resetInMinutes || 60);
setScreen("rate_limited");
return;
}
if (!response.ok) {
if (data.isSafetyViolation) {
setSafetyWarning(data.error);
} else {
setErrorMessage(data.error || "Generation failed. Please try again.");
}
setScreen("style");
return;
}
setResultImage(data.imageUrl);
setProviderInfo({
provider: data.provider,
model: data.model,
durationMs: data.durationMs,
fallbackTriggered: data.fallbackTriggered,
});
setScreen("result");
} catch (err: any) {
console.error("API call error:", err);
setErrorMessage(
t({
en: "Network error. Please check your internet connection and try again.",
zh: "网络连接异常,请检查网络后重试。"
})
);
setScreen("style");
}
};
const handleStartOver = () => {
setCapturedPhoto(null);
setSelectedStyle(null);
setResultImage(null);
setErrorMessage(null);
setSafetyWarning(null);
setScreen("standby");
};
if (!config) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#090314] text-white">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 rounded-full border-4 border-amber-500 border-t-transparent animate-spin" />
<p className="text-sm font-bold text-amber-300">🥋 加载万圣道馆中...</p>
</div>
</div>
);
}
return (
<KioskFrame
config={config}
showHeaderLogo={screen === "standby"}
showLanguageToggle={screen === "standby"}
>
{safetyWarning && (
<div className="absolute top-2 left-4 right-4 z-50 p-3 bg-red-600/90 text-white rounded-2xl border-2 border-black shadow-[0_4px_0_#090314] flex items-center space-x-2 text-xs font-bold animate-bounce">
<AlertTriangle size={18} className="shrink-0" />
<span>{safetyWarning}</span>
</div>
)}
{errorMessage && (
<div className="absolute top-2 left-4 right-4 z-50 p-3 bg-amber-600/90 text-white rounded-2xl border-2 border-black shadow-[0_4px_0_#090314] flex items-center space-x-2 text-xs font-bold animate-pulse">
<AlertTriangle size={18} className="shrink-0" />
<span>{errorMessage}</span>
</div>
)}
{screen === "standby" && (
<StandbyScreen config={config} onStart={handleStart} />
)}
{screen === "camera" && (
<CameraScreen
config={config}
onPhotoCaptured={handlePhotoCaptured}
onBack={handleStartOver}
/>
)}
{screen === "confirm" && capturedPhoto && (
<ConfirmScreen
config={config}
photoUrl={capturedPhoto}
onConfirm={handleConfirmPhoto}
onRetake={handleRetake}
/>
)}
{screen === "style" && (
<StyleScreen
config={config}
styles={styles}
onStyleSelected={handleStyleSelected}
onBack={() => setScreen("confirm")}
/>
)}
{screen === "processing" && <LoadingScreen config={config} />}
{screen === "result" && resultImage && (
<ResultScreen
config={config}
resultImageUrl={resultImage}
providerInfo={providerInfo}
onStartOver={handleStartOver}
/>
)}
{screen === "rate_limited" && (
<RateLimitedScreen
config={config}
resetMinutes={rateLimitResetMinutes}
onReset={handleStartOver}
/>
)}
</KioskFrame>
);
}
export default function PhotoBoothPage() {
return (
<I18nProvider>
<PhotoBoothApp />
</I18nProvider>
);
}