Skip to content

Instantly share code, notes, and snippets.

@Vilin97
Created February 15, 2026 19:07
Show Gist options
  • Select an option

  • Save Vilin97/7eca0f4c08790898ee49b233b8aa1ae2 to your computer and use it in GitHub Desktop.

Select an option

Save Vilin97/7eca0f4c08790898ee49b233b8aa1ae2 to your computer and use it in GitHub Desktop.
History explorer javascript
import { useState, useRef, useCallback, useEffect } from "react";
/* ─── constants ─── */
const LOADING_PHRASES = [
"Digging through the archives…",
"Unearthing forgotten scandals…",
"Consulting dusty manuscripts…",
"Interviewing ghosts…",
"Decoding secret diaries…",
];
const LANGUAGES = [
{ code: "auto", label: "Auto-detect" },
{ code: "en", label: "English" }, { code: "ru", label: "Русский" },
{ code: "fr", label: "Français" }, { code: "de", label: "Deutsch" },
{ code: "es", label: "Español" }, { code: "it", label: "Italiano" },
{ code: "pt", label: "Português" }, { code: "ja", label: "日本語" },
{ code: "zh", label: "中文" }, { code: "ko", label: "한국어" },
{ code: "ar", label: "العربية" }, { code: "hi", label: "हिन्दी" },
{ code: "tr", label: "Türkçe" }, { code: "pl", label: "Polski" },
{ code: "uk", label: "Українська" }, { code: "nl", label: "Nederlands" },
{ code: "sv", label: "Svenska" }, { code: "cs", label: "Čeština" },
{ code: "el", label: "Ελληνικά" },
];
const ELEVEN_VOICE_ID = "21m00Tcm4TlvDq8ikWAM";
const T = {
bg: "#1a1714", paper: "#f4efe6", paperDark: "#e8e0d0",
ink: "#2c2418", inkLight: "#5a4e3c",
accent: "#8b3a2a", accentGlow: "#c4563f",
gold: "#c9a84c", goldDim: "#a08530",
};
const getLangLabel = (code) => LANGUAGES.find((l) => l.code === code)?.label || code;
function detectLanguage(text) {
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return "ja";
if (/[\uAC00-\uD7AF]/.test(text)) return "ko";
if (/[\u4E00-\u9FFF]/.test(text)) return "zh";
if (/[\u0600-\u06FF]/.test(text)) return "ar";
if (/[\u0900-\u097F]/.test(text)) return "hi";
if (/[\u0370-\u03FF]/.test(text)) return "el";
if (/[\u0400-\u04FF]/.test(text)) return /[іїєґ]/i.test(text) ? "uk" : "ru";
const lo = text.toLowerCase();
if (/[àâçéèêëîïôùûü]/.test(text) || /\b(rue|boulevard)\b/.test(lo)) return "fr";
if (/[äöüß]/.test(text) || /\b(straße|platz)\b/.test(lo)) return "de";
if (/[ñ¿¡]/.test(text) || /\b(calle|plaza)\b/.test(lo)) return "es";
if (/\b(via|piazza|palazzo)\b/.test(lo)) return "it";
if (/[ğşı]/.test(text) || /\b(sokak|cadde)\b/.test(lo)) return "tr";
if (/[ąćęłń]/.test(text)) return "pl";
return "en";
}
const countryLangMap = {
ru:"ru",ua:"uk",fr:"fr",de:"de",at:"de",ch:"de",es:"es",it:"it",pt:"pt",
br:"pt",jp:"ja",cn:"zh",tw:"zh",kr:"ko",tr:"tr",pl:"pl",nl:"nl",se:"sv",
cz:"cs",gr:"el",sa:"ar",eg:"ar",in:"hi",
};
/* ─── main ─── */
export default function HistoryExplorer() {
const [address, setAddress] = useState("");
const [manualInput, setManualInput] = useState("");
const [loading, setLoading] = useState(false);
const [geoLoading, setGeoLoading] = useState(false);
const [stories, setStories] = useState(null);
const [error, setError] = useState(null);
const [loadingPhrase, setLoadingPhrase] = useState(LOADING_PHRASES[0]);
const [mode, setMode] = useState("idle");
const [selectedLang, setSelectedLang] = useState("auto");
const [detectedLang, setDetectedLang] = useState(null);
const [elevenLabsKey, setElevenLabsKey] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [ttsProvider, setTtsProvider] = useState("elevenlabs");
const [playingIdx, setPlayingIdx] = useState(-1);
const [isPlaying, setIsPlaying] = useState(false);
const [audioStatus, setAudioStatus] = useState(""); // user-visible status
const cancelRef = useRef(false);
const audioPoolRef = useRef([]); // track all active Audio objects
useEffect(() => {
if (loading) {
let i = 0;
const iv = setInterval(() => { i = (i + 1) % LOADING_PHRASES.length; setLoadingPhrase(LOADING_PHRASES[i]); }, 2200);
return () => clearInterval(iv);
}
}, [loading]);
const getEffectiveLang = useCallback(() => {
if (selectedLang !== "auto") return selectedLang;
return detectedLang || "en";
}, [selectedLang, detectedLang]);
/* ─── stop everything ─── */
const stopAll = useCallback(() => {
cancelRef.current = true;
audioPoolRef.current.forEach((a) => { try { a.pause(); a.src = ""; } catch {} });
audioPoolRef.current = [];
window.speechSynthesis?.cancel();
setIsPlaying(false);
setPlayingIdx(-1);
setAudioStatus("");
}, []);
/* ─── ElevenLabs: generate SFX ─── */
const generateSFX = useCallback(async (prompt, duration = 10) => {
if (!elevenLabsKey) return null;
try {
setAudioStatus(`🎵 Generating sound: "${prompt.slice(0, 40)}…"`);
const res = await fetch("https://api.elevenlabs.io/v1/sound-generation", {
method: "POST",
headers: { "xi-api-key": elevenLabsKey, "Content-Type": "application/json" },
body: JSON.stringify({
text: prompt,
duration_seconds: Math.min(Math.max(duration, 0.5), 22),
prompt_influence: 0.3,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error("SFX API error:", res.status, errText);
setAudioStatus(`⚠️ SFX failed (${res.status}) — skipping`);
return null;
}
const buf = await res.arrayBuffer();
if (buf.byteLength < 1000) { console.error("SFX response too small:", buf.byteLength); return null; }
const blob = new Blob([buf], { type: "audio/mpeg" });
const url = URL.createObjectURL(blob);
return url;
} catch (err) {
console.error("SFX generation error:", err);
return null;
}
}, [elevenLabsKey]);
/* ─── ElevenLabs: generate TTS ─── */
const generateTTS = useCallback(async (text, langCode) => {
if (!elevenLabsKey) return null;
try {
setAudioStatus("🗣️ Generating narration…");
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE_ID}`, {
method: "POST",
headers: { "xi-api-key": elevenLabsKey, "Content-Type": "application/json" },
body: JSON.stringify({
text,
model_id: "eleven_multilingual_v2",
language_code: langCode,
voice_settings: { stability: 0.5, similarity_boost: 0.75, style: 0.35, use_speaker_boost: true },
}),
});
if (!res.ok) {
console.error("TTS API error:", res.status);
return null;
}
const buf = await res.arrayBuffer();
const blob = new Blob([buf], { type: "audio/mpeg" });
return URL.createObjectURL(blob);
} catch (err) { console.error("TTS error:", err); return null; }
}, [elevenLabsKey]);
/* ─── play a blob url with volume, optional loop ─── */
const playAudioUrl = useCallback((url, volume = 1.0, loop = false) => {
return new Promise((resolve) => {
const audio = new Audio();
audioPoolRef.current.push(audio);
audio.volume = volume;
audio.loop = loop;
audio.src = url;
audio.oncanplaythrough = () => {
audio.play().then(() => {
if (loop) resolve(audio); // for loops, resolve immediately with the audio ref
}).catch((e) => { console.error("play() failed:", e); resolve(null); });
};
if (!loop) {
audio.onended = () => resolve(audio);
audio.onerror = () => { console.error("Audio error for url"); resolve(null); };
}
audio.onerror = () => { console.error("Audio load error"); resolve(null); };
audio.load();
});
}, []);
/* ─── fade out an audio element ─── */
const fadeOut = useCallback((audio, ms = 1200) => {
return new Promise((resolve) => {
if (!audio || audio.paused) { resolve(); return; }
const start = audio.volume;
const steps = 20;
const stepVol = start / steps;
const stepMs = ms / steps;
let vol = start;
const iv = setInterval(() => {
vol -= stepVol;
if (vol <= 0.01) {
audio.volume = 0; audio.pause();
clearInterval(iv); resolve();
} else { audio.volume = Math.max(0, vol); }
}, stepMs);
});
}, []);
/* ─── browser TTS fallback ─── */
const speakBrowser = useCallback((text, langCode) => {
return new Promise((resolve) => {
const utt = new SpeechSynthesisUtterance(text);
utt.rate = 0.93; utt.lang = langCode;
const voices = window.speechSynthesis.getVoices();
const v = voices.find((x) => x.lang.startsWith(langCode)) || voices[0];
if (v) utt.voice = v;
utt.onend = resolve; utt.onerror = resolve;
window.speechSynthesis.speak(utt);
});
}, []);
/* ─── play one story immersively ─── */
const playStory = useCallback(async (idx) => {
if (!stories) return;
const story = stories.stories[idx];
if (!story) return;
stopAll();
cancelRef.current = false;
setPlayingIdx(idx);
setIsPlaying(true);
const langCode = getEffectiveLang();
const useEleven = ttsProvider === "elevenlabs" && !!elevenLabsKey;
let ambientUrl = null;
let sfxUrl = null;
let narrationUrl = null;
if (useEleven) {
// Generate all audio in parallel
setAudioStatus("⏳ Generating all audio layers…");
const results = await Promise.allSettled([
stories.ambient_sfx ? generateSFX(stories.ambient_sfx, 20) : Promise.resolve(null),
story.sfx_prompt ? generateSFX(story.sfx_prompt, 8) : Promise.resolve(null),
generateTTS(story.text, langCode),
]);
if (cancelRef.current) return;
ambientUrl = results[0].status === "fulfilled" ? results[0].value : null;
sfxUrl = results[1].status === "fulfilled" ? results[1].value : null;
narrationUrl = results[2].status === "fulfilled" ? results[2].value : null;
console.log("Audio URLs:", { ambientUrl, sfxUrl, narrationUrl });
}
if (cancelRef.current) return;
// === PLAYBACK SEQUENCE ===
let ambientAudio = null;
// 1) Start ambient loop
if (ambientUrl) {
setAudioStatus("🎧 Playing ambient…");
ambientAudio = await playAudioUrl(ambientUrl, 0.15, true);
if (cancelRef.current) return;
await new Promise((r) => setTimeout(r, 1200));
}
if (cancelRef.current) return;
// 2) Play story SFX
if (sfxUrl) {
setAudioStatus("🔊 Playing sound effect…");
// Don't await — let it play underneath
playAudioUrl(sfxUrl, 0.4, false);
await new Promise((r) => setTimeout(r, 1500));
}
if (cancelRef.current) return;
// 3) Narration
if (narrationUrl) {
setAudioStatus("🗣️ Narrating…");
// Lower ambient while narrating
if (ambientAudio) ambientAudio.volume = 0.07;
await playAudioUrl(narrationUrl, 1.0, false);
} else {
setAudioStatus("🗣️ Narrating…");
if (ambientAudio) ambientAudio.volume = 0.07;
await speakBrowser(story.text, langCode);
}
if (cancelRef.current) return;
// 4) Fade out ambient
if (ambientAudio) {
ambientAudio.volume = 0.15;
await new Promise((r) => setTimeout(r, 500));
await fadeOut(ambientAudio, 2000);
}
setIsPlaying(false);
setPlayingIdx(-1);
setAudioStatus("");
}, [stories, stopAll, getEffectiveLang, ttsProvider, elevenLabsKey, generateSFX, generateTTS, playAudioUrl, fadeOut, speakBrowser]);
/* ─── play all stories ─── */
const playAll = useCallback(async () => {
if (!stories) return;
for (let i = 0; i < stories.stories.length; i++) {
if (cancelRef.current) break;
await playStory(i);
if (cancelRef.current) break;
if (i < stories.stories.length - 1) await new Promise((r) => setTimeout(r, 800));
}
}, [stories, playStory]);
/* ─── fetch from LLM ─── */
const fetchHistory = useCallback(async (locationStr, langOverride) => {
setLoading(true); setError(null); setStories(null);
const langCode = langOverride || getEffectiveLang();
const langLabel = getLangLabel(langCode);
try {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1500,
messages: [{
role: "user",
content: `You are a captivating storyteller who knows the JUICIEST, most surprising, dramatic, bizarre, and little-known historical stories about specific places.
The user is at: ${locationStr}
Write in ${langLabel} (code: ${langCode}). ALL text fields must be in ${langLabel}.
Give me 2-3 SHORT, punchy stories about the most INTERESTING things that happened here. NOT a boring overview — I want:
- Scandals, secret meetings, love affairs, duels, conspiracies
- Bizarre events, accidents, unexplained occurrences
- Pivotal moments that changed history
- Famous people doing unexpected things at this exact spot
- Underground movements, banned art, revolutionary acts
Each story: 2-3 sentences MAX. Vivid, specific, dates and names. Like a friend whispering a secret.
Also provide sound design prompts for immersive audio.
Respond ONLY with JSON (no markdown, no backticks):
{
"place_name": "Short evocative name in ${langLabel}",
"ambient_sfx": "Background ambient sound prompt for this place/era — be cinematic and specific, e.g. 'cobblestone street with horse carriages, distant church bells, wind, and murmur of a 19th century Russian crowd' — 10-20 words max",
"stories": [
{
"year": "1920",
"headline": "Punchy headline 3-8 words in ${langLabel}",
"text": "The story in 2-3 vivid sentences in ${langLabel}",
"sfx_prompt": "Sound effect for this specific story — e.g. 'crackling fire with distant shouts and breaking wood' or 'piano playing classical music in a grand hall with applause' — be specific and cinematic, 8-15 words",
"mood": "dramatic | mysterious | romantic | chaotic | triumphant | dark | whimsical"
}
]
}`
}],
}),
});
const data = await response.json();
const text = data.content?.map((c) => c.text || "").join("") || "";
const cleaned = text.replace(/```json|```/g, "").trim();
const parsed = JSON.parse(cleaned);
setStories(parsed);
setMode("result");
} catch (err) {
setError("Couldn't fetch stories. Please try again.");
console.error(err);
} finally { setLoading(false); }
}, [getEffectiveLang]);
const handleGeolocate = useCallback(async () => {
if (!navigator.geolocation) { setError("Geolocation not supported."); return; }
setGeoLoading(true); setError(null);
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude, longitude } = pos.coords;
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&addressdetails=1&accept-language=en`);
const geo = await res.json();
const addr = geo.display_name || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`;
let newLang = "en";
if (selectedLang === "auto" && geo.address?.country_code) {
newLang = countryLangMap[geo.address.country_code] || "en";
setDetectedLang(newLang);
}
setAddress(addr); setGeoLoading(false);
fetchHistory(addr, selectedLang === "auto" ? newLang : undefined);
} catch {
const fb = `${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)}`;
setAddress(fb); setGeoLoading(false); fetchHistory(fb);
}
},
() => { setGeoLoading(false); setError("Couldn't get location."); },
{ enableHighAccuracy: true, timeout: 15000 }
);
}, [fetchHistory, selectedLang]);
const handleManualSubmit = useCallback(() => {
if (!manualInput.trim()) return;
const input = manualInput.trim();
let newLang;
if (selectedLang === "auto") { newLang = detectLanguage(input); setDetectedLang(newLang); }
setAddress(input);
fetchHistory(input, newLang);
}, [manualInput, fetchHistory, selectedLang]);
const handleReset = useCallback(() => {
stopAll(); setMode("idle"); setStories(null); setAddress("");
setManualInput(""); setError(null); setDetectedLang(null);
}, [stopAll]);
const effectiveLang = getEffectiveLang();
const moodColors = { dramatic:"#c4563f", mysterious:"#6b5b8a", romantic:"#b5547a", chaotic:"#d4822e", triumphant:"#c9a84c", dark:"#4a4a4a", whimsical:"#5a8a6b" };
const moodEmoji = { dramatic:"🔥", mysterious:"🌑", romantic:"💘", chaotic:"⚡", triumphant:"👑", dark:"🖤", whimsical:"✨" };
return (
<div style={{ minHeight: "100vh", background: T.bg, fontFamily: "'Crimson Text', Georgia, serif", color: T.ink }}>
<div style={{ position: "fixed", inset: 0, opacity: 0.04, pointerEvents: "none", backgroundImage: `radial-gradient(circle at 20% 50%, ${T.gold} 0%, transparent 50%), radial-gradient(circle at 80% 20%, ${T.accent} 0%, transparent 40%)` }} />
<div style={{ maxWidth: 640, margin: "0 auto", padding: "48px 24px 100px", position: "relative", zIndex: 1 }}>
{/* Header */}
<header style={{ textAlign: "center", marginBottom: 36 }}>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}>
<button onClick={() => setShowSettings(!showSettings)} style={{
background: showSettings ? `${T.gold}22` : "transparent",
border: `1px solid ${T.paper}15`, color: T.paperDark,
padding: "6px 14px", borderRadius: 5, fontSize: 12,
fontFamily: "'Courier New', monospace", cursor: "pointer",
display: "flex", alignItems: "center", gap: 6,
opacity: showSettings ? 0.8 : 0.5, transition: "opacity 0.2s",
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = 1; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = showSettings ? 0.8 : 0.5; }}
>⚙ Settings</button>
</div>
{showSettings && (
<div style={{ background: `${T.paper}0a`, border: `1px solid ${T.paper}12`, borderRadius: 8, padding: "20px 24px", marginBottom: 24, textAlign: "left", animation: "fadeIn 0.3s" }}>
<div style={{ fontSize: 11, letterSpacing: 3, textTransform: "uppercase", color: T.gold, marginBottom: 14, fontFamily: "'Courier New', monospace" }}>Voice & Sound Engine</div>
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
{[
{ id: "elevenlabs", label: "ElevenLabs", sub: "AI voice + ambient + SFX" },
{ id: "browser", label: "Browser TTS", sub: "Free, voice only" },
].map((p) => (
<button key={p.id} onClick={() => setTtsProvider(p.id)} style={{
flex: 1, padding: "12px 14px",
background: ttsProvider === p.id ? `${T.gold}18` : "transparent",
border: `1px solid ${ttsProvider === p.id ? T.gold + "44" : T.paper + "10"}`,
borderRadius: 6, color: ttsProvider === p.id ? T.paper : T.paperDark,
cursor: "pointer", textAlign: "left",
}}>
<div style={{ fontSize: 14, fontFamily: "inherit", fontWeight: 600 }}>{p.label}</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 2, fontFamily: "'Courier New', monospace" }}>{p.sub}</div>
</button>
))}
</div>
{ttsProvider === "elevenlabs" && (
<div>
<label style={{ fontSize: 12, color: T.paperDark, opacity: 0.5, display: "block", marginBottom: 6 }}>ElevenLabs API Key</label>
<input type="password" value={elevenLabsKey} onChange={(e) => setElevenLabsKey(e.target.value)}
placeholder="xi-xxxxxxxxxxxxxxxx"
style={{ width: "100%", padding: "10px 14px", background: `${T.paper}08`, border: `1px solid ${T.paper}15`, borderRadius: 5, fontSize: 13, fontFamily: "'Courier New', monospace", color: T.paper, outline: "none", boxSizing: "border-box" }}
/>
{!elevenLabsKey && <div style={{ fontSize: 11, color: T.accentGlow, opacity: 0.6, marginTop: 6 }}>Get a free key at elevenlabs.io → enables AI narration + ambient + sound effects</div>}
</div>
)}
</div>
)}
<div style={{ fontSize: 11, letterSpacing: 6, textTransform: "uppercase", color: T.gold, marginBottom: 10, fontFamily: "'Courier New', monospace" }}>✦ Locus Historiae ✦</div>
<h1 style={{ fontSize: "clamp(28px, 6vw, 42px)", fontWeight: 400, color: T.paper, lineHeight: 1.15, margin: 0, fontStyle: "italic" }}>What <em>really</em> happened<br />right here?</h1>
<div style={{ width: 60, height: 1, background: `linear-gradient(90deg, transparent, ${T.gold}, transparent)`, margin: "18px auto 14px" }} />
<p style={{ fontSize: 15, lineHeight: 1.6, maxWidth: 420, margin: "0 auto", opacity: 0.45, color: T.paperDark }}>
The scandals, secrets, and strange events beneath your feet.
</p>
</header>
{/* Input */}
{mode === "idle" && !loading && (
<div style={{ animation: "fadeIn 0.6s" }}>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 11, letterSpacing: 2, textTransform: "uppercase", color: T.paperDark, opacity: 0.4, display: "block", marginBottom: 8, fontFamily: "'Courier New', monospace" }}>Language</label>
<div style={{ position: "relative" }}>
<select value={selectedLang} onChange={(e) => setSelectedLang(e.target.value)} style={{
width: "100%", padding: "12px 16px", paddingRight: 40, background: `${T.paper}0a`, border: `1px solid ${T.paper}18`, borderRadius: 6, fontSize: 15, fontFamily: "inherit", color: T.paper, outline: "none", cursor: "pointer", appearance: "none", WebkitAppearance: "none",
}}>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code} style={{ background: T.bg, color: T.paper }}>{l.label}{l.code === "auto" ? " → detected from input" : ""}</option>
))}
</select>
<svg style={{ position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={T.paperDark} strokeWidth="2"><polyline points="6 9 12 15 18 9" /></svg>
</div>
</div>
<button onClick={handleGeolocate} disabled={geoLoading} style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 12, width: "100%", padding: "18px 24px", background: T.accent, color: T.paper, border: "none", borderRadius: 6, fontSize: 16, fontFamily: "inherit", cursor: geoLoading ? "wait" : "pointer", boxShadow: `0 2px 20px ${T.accent}33`,
}}
onMouseEnter={(e) => { if (!geoLoading) e.currentTarget.style.background = T.accentGlow; }}
onMouseLeave={(e) => { e.currentTarget.style.background = T.accent; }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" /></svg>
{geoLoading ? "Getting location…" : "Use My Location"}
</button>
<div style={{ display: "flex", alignItems: "center", gap: 16, margin: "22px 0", color: T.paperDark, opacity: 0.3, fontSize: 12, letterSpacing: 3, textTransform: "uppercase" }}>
<div style={{ flex: 1, height: 1, background: T.paperDark }} />or<div style={{ flex: 1, height: 1, background: T.paperDark }} />
</div>
<div style={{ display: "flex", gap: 10 }}>
<input type="text" value={manualInput} onChange={(e) => setManualInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()}
placeholder="Дом Юшкова, Мясницкая 21, Москва"
style={{ flex: 1, padding: "14px 18px", background: `${T.paper}0d`, border: `1px solid ${T.paper}1a`, borderRadius: 6, fontSize: 15, fontFamily: "inherit", color: T.paper, outline: "none" }}
onFocus={(e) => { e.target.style.borderColor = `${T.gold}55`; }}
onBlur={(e) => { e.target.style.borderColor = `${T.paper}1a`; }}
/>
<button onClick={handleManualSubmit} disabled={!manualInput.trim()} style={{
padding: "14px 20px", background: T.gold, color: T.bg, border: "none", borderRadius: 6, fontSize: 15, fontWeight: 700, fontFamily: "inherit", cursor: "pointer", opacity: !manualInput.trim() ? 0.4 : 1,
}}>→</button>
</div>
</div>
)}
{loading && (
<div style={{ textAlign: "center", padding: "60px 0", animation: "fadeIn 0.4s" }}>
<div style={{ width: 48, height: 48, margin: "0 auto 24px", border: `2px solid ${T.paper}15`, borderTopColor: T.gold, borderRadius: "50%", animation: "spin 1s linear infinite" }} />
<p style={{ color: T.paperDark, fontSize: 15, fontStyle: "italic", opacity: 0.6 }}>{loadingPhrase}</p>
</div>
)}
{error && <div style={{ padding: "16px 20px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 6, color: T.accentGlow, fontSize: 14, marginTop: 16 }}>{error}</div>}
{/* ─── RESULTS ─── */}
{stories && mode === "result" && (
<div style={{ animation: "slideUp 0.6s" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 12, color: T.paperDark, opacity: 0.4, marginBottom: 8, fontFamily: "'Courier New', monospace", flexWrap: "wrap", gap: 8 }}>
<span>📍 {address}</span>
<span style={{ padding: "2px 10px", background: `${T.gold}15`, borderRadius: 3, color: T.gold }}>🌐 {getLangLabel(effectiveLang)}</span>
</div>
<h2 style={{ fontSize: "clamp(24px, 5vw, 34px)", fontWeight: 400, fontStyle: "italic", color: T.paper, margin: "0 0 20px", lineHeight: 1.2 }}>{stories.place_name}</h2>
{/* Play All */}
<button
onClick={() => isPlaying ? stopAll() : playAll()}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 10, width: "100%", padding: "16px 24px", marginBottom: 8,
background: isPlaying ? T.ink : `linear-gradient(135deg, ${T.accent}, ${T.accentGlow})`,
color: T.paper, border: "none", borderRadius: 8, fontSize: 15, fontFamily: "inherit", cursor: "pointer",
boxShadow: isPlaying ? "none" : `0 4px 24px ${T.accent}44`,
}}
>
{isPlaying ? (
<><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" /></svg>Stop</>
) : (
<><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
{ttsProvider === "elevenlabs" && elevenLabsKey ? "▶ Play Immersive Tour" : "▶ Play All Stories"}</>
)}
</button>
{/* Audio status bar */}
{audioStatus && (
<div style={{
textAlign: "center", fontSize: 12, color: T.gold, opacity: 0.7,
padding: "8px 0 4px", fontFamily: "'Courier New', monospace",
animation: "fadeIn 0.3s",
}}>
{audioStatus}
</div>
)}
{elevenLabsKey && ttsProvider === "elevenlabs" && !audioStatus && (
<div style={{ textAlign: "center", fontSize: 11, color: T.paperDark, opacity: 0.25, padding: "6px 0 0", fontFamily: "'Courier New', monospace" }}>
🎧 ambient + narration + sound effects
</div>
)}
<div style={{ height: 16 }} />
{/* Story Cards */}
{stories.stories.map((story, i) => {
const mc = moodColors[story.mood] || T.accent;
const me = moodEmoji[story.mood] || "📜";
const isActive = playingIdx === i;
return (
<div key={i} style={{
background: T.paper, borderRadius: 8, overflow: "hidden", marginBottom: 16,
boxShadow: isActive ? `0 0 0 2px ${mc}, 0 4px 30px ${mc}33` : `0 2px 16px ${T.bg}66`,
transition: "box-shadow 0.4s", position: "relative",
}}>
{isActive && (
<div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: `linear-gradient(90deg, ${mc}, ${T.gold}, ${mc})`,
backgroundSize: "200% 100%", animation: "shimmer 2s linear infinite",
}} />
)}
<div style={{ padding: "22px 24px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
<span style={{ fontSize: 13, fontFamily: "'Courier New', monospace", color: mc, fontWeight: 700, letterSpacing: 1 }}>{story.year}</span>
<span style={{ padding: "2px 8px", background: `${mc}15`, color: mc, borderRadius: 3, fontSize: 10, letterSpacing: 1.5, textTransform: "uppercase", fontFamily: "'Courier New', monospace" }}>{me} {story.mood}</span>
</div>
<h3 style={{ fontSize: 20, fontWeight: 600, fontStyle: "italic", lineHeight: 1.3, margin: "0 0 10px", color: T.ink }}>{story.headline}</h3>
<p style={{ fontSize: 15, lineHeight: 1.7, color: T.ink, margin: 0, opacity: 0.85 }}>{story.text}</p>
{elevenLabsKey && story.sfx_prompt && (
<div style={{ marginTop: 12, fontSize: 11, color: T.inkLight, opacity: 0.35, fontFamily: "'Courier New', monospace", display: "flex", alignItems: "center", gap: 6 }}>
🔊 {story.sfx_prompt}
</div>
)}
</div>
<div style={{ borderTop: `1px solid ${T.paperDark}`, padding: "10px 24px", display: "flex", alignItems: "center", gap: 10 }}>
<button
onClick={() => isActive && isPlaying ? stopAll() : playStory(i)}
style={{
display: "flex", alignItems: "center", gap: 6, padding: "7px 14px",
background: isActive ? T.ink : mc, color: T.paper,
border: "none", borderRadius: 4, fontSize: 12, fontFamily: "inherit", cursor: "pointer",
}}
>
{isActive && isPlaying ? (
<><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" /></svg>Stop</>
) : (
<><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>Listen</>
)}
</button>
</div>
</div>
);
})}
<button onClick={handleReset} style={{
display: "block", margin: "28px auto 0", padding: "12px 28px",
background: "transparent", color: T.paperDark,
border: `1px solid ${T.paper}1a`, borderRadius: 5, fontSize: 14,
fontFamily: "inherit", cursor: "pointer", opacity: 0.5,
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = 1; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = 0.5; }}
>← Explore Another Location</button>
</div>
)}
</div>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap');
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px) } to { opacity: 1; transform: translateY(0) } }
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
::placeholder { color: #f4efe644; font-style: italic }
* { box-sizing: border-box }
`}</style>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment