Created
February 15, 2026 19:07
-
-
Save Vilin97/7eca0f4c08790898ee49b233b8aa1ae2 to your computer and use it in GitHub Desktop.
History explorer javascript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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