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