initial commit

This commit is contained in:
2025-09-09 17:29:49 +02:00
commit cb26424173
18 changed files with 2783 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>10AM</title>
</head>
<body class="bg-black text-white font-helvetica antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "10am-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^9.92.5",
"@react-three/fiber": "^8.15.12",
"framer-motion": "^10.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"three": "^0.158.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/three": "^0.158.0",
"@vitejs/plugin-react": "^4.1.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="https://planetpixelemporium.com/download/download.php?8081/earthspec2k.jpg">here</a>.</p>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

477
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,477 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Canvas, useFrame, ThreeEvent, useLoader } from '@react-three/fiber';
import * as THREE from 'three';
import { motion, AnimatePresence } from 'framer-motion';
// ---------- Types ----------
interface TenAmResult {
country: string;
capital: string;
tzid: string;
local_time: string;
utc_now: string;
/** English summary or null */
summary_en: string | null;
/** German translation or null */
summary_de: string | null;
/** Japanese translation or null */
summary_jp: string | null;
/** Optional summary alias when backend is queried with ?lang */
summary?: string | null;
}
interface TenAmResponse {
count: number;
exact: boolean;
at_utc: string;
results: TenAmResult[];
}
/** New grouped endpoint types */
interface NowGroup {
label: string; // "HH:MM"
results: TenAmResult[];
}
interface NowGroupsResponse {
at_utc: string;
groups: NowGroup[];
next_cursor: string | null;
}
// ---------- Cookie & language helpers ----------
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const part = parts.pop();
if (part) {
return part.split(';').shift() || null;
}
}
return null;
}
function setCookie(name: string, value: string, maxAgeSeconds: number = 60 * 60 * 24 * 180) {
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; path=/`;
}
function detectDefaultLang(): string {
// 1. Cookie
const ck = getCookie('lang');
if (ck && ['en', 'de', 'jp'].includes(ck)) return ck;
// 2. Browser preferences
if (typeof navigator !== 'undefined') {
const langs = (navigator.languages || [navigator.language || '']).map(l => l.toLowerCase());
for (const l of langs) {
if (l.startsWith('de')) return 'de';
if (l.startsWith('ja') || l.startsWith('jp')) return 'jp';
}
}
// Default
return 'en';
}
// ---------- Group fetching helpers ----------
function msUntilNextQuarter(): number {
const now = new Date();
const mins = now.getUTCMinutes();
const secs = now.getUTCSeconds();
const nextQuarter = ((Math.floor(mins / 15) + 1) * 15) % 60;
const minDelta = (nextQuarter - mins + 60) % 60;
return minDelta * 60_000 - secs * 1000;
}
// ---------- Pure helpers (NO hooks here) ----------
const DEG2RAD = THREE.MathUtils.DEG2RAD;
function rotationDegFor10am(nowUtc: Date): number {
// 15° per hour; local 10:00 line in relation to UTC
const utcHours = nowUtc.getUTCHours() + nowUtc.getUTCMinutes() / 60 + nowUtc.getUTCSeconds() / 3600;
let lon = 15 * (10 + utcHours);
lon -= 30; // small framing shift
return lon;
}
// ---------- Globe ----------
function Globe({
onManualRotate,
onDragStart,
}: {
onManualRotate: (scrubOffset: number, isDragEnd: boolean) => void;
onDragStart?: (scrubOffset: number) => void;
}) {
const globeRef = useRef<THREE.Group>(null);
const texture = useLoader(THREE.TextureLoader, '/world-map.png'); // your file in /public
const isDragging = useRef(false);
const previousMousePos = useRef({ x: 0, y: 0 });
const [scrubOffset, setScrubOffset] = useState(0);
const scrubOffsetRef = useRef(0);
useEffect(() => { scrubOffsetRef.current = scrubOffset; }, [scrubOffset]);
const detachRef = useRef<(() => void) | null>(null);
useFrame(() => {
if (!globeRef.current) return;
const now = new Date();
const rotDeg = rotationDegFor10am(now);
globeRef.current.rotation.y = rotDeg * DEG2RAD + scrubOffset;
});
const handleWindowMove = useCallback((ev: PointerEvent) => {
if (!isDragging.current) return;
const deltaX = ev.clientX - previousMousePos.current.x;
previousMousePos.current = { x: ev.clientX, y: ev.clientY };
setScrubOffset((prev) => {
const next = Math.min(0, prev + deltaX * 0.005); // allow “past” only
onManualRotate(next, false);
return next;
});
}, [onManualRotate]);
const handleWindowUp = useCallback((_ev: Event) => {
if (!isDragging.current) return;
isDragging.current = false;
onManualRotate(scrubOffsetRef.current, true);
// cleanup listeners
detachRef.current?.();
detachRef.current = null;
}, [onManualRotate]);
const attachWindowListeners = useCallback(() => {
const move = (ev: PointerEvent) => handleWindowMove(ev);
const up = (ev: Event) => handleWindowUp(ev);
window.addEventListener('pointermove', move, { passive: true });
window.addEventListener('pointerup', up, { passive: true });
window.addEventListener('pointercancel', up, { passive: true });
window.addEventListener('blur', up, { passive: true });
detachRef.current = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', up);
window.removeEventListener('blur', up);
};
}, [handleWindowMove, handleWindowUp]);
// Ensure listeners are removed if component unmounts mid-drag
useEffect(() => {
return () => {
detachRef.current?.();
detachRef.current = null;
isDragging.current = false;
};
}, []);
const onPointerDown = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation();
isDragging.current = true;
previousMousePos.current = { x: event.clientX, y: event.clientY };
(event.nativeEvent.target as HTMLElement).style.cursor = 'grabbing';
onDragStart?.(scrubOffset);
attachWindowListeners();
};
// If user releases inside the canvas, we still finalize here;
// window 'pointerup' will also run but we guard on isDragging.
const onPointerUp = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation();
if (!isDragging.current) return;
isDragging.current = false;
(event.nativeEvent.target as HTMLElement).style.cursor = 'grab';
onManualRotate(scrubOffsetRef.current, true);
detachRef.current?.();
detachRef.current = null;
};
// While dragging, we let the WINDOW listener handle movement to catch outside-canvas drags.
const onPointerMove = (event: ThreeEvent<PointerEvent>) => {
if (!isDragging.current) return;
event.stopPropagation();
// No-op here; window 'pointermove' handles the updates (avoids double-handling).
};
// Leaving the canvas shouldn't end the drag; window listeners keep handling it.
const onPointerOut = (event: ThreeEvent<PointerEvent>) => {
if (!isDragging.current) return;
event.stopPropagation();
// Intentionally do nothing drag continues until pointerup anywhere.
};
return (
<group ref={globeRef}>
<mesh
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
scale={[1.8, 1.8, 1.8]}
>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial map={texture} color="white" />
</mesh>
</group>
);
}
// ---------- Country card ----------
const CountryCard = ({ country, lang }: { country: TenAmResult; lang: string }) => {
// Determine which summary to display based on current language. Fallback to English.
let display: string | null = null;
if (lang === 'de') {
display = country.summary_de ?? country.summary_en ?? null;
} else if (lang === 'jp') {
display = country.summary_jp ?? country.summary_en ?? null;
} else {
display = country.summary_en ?? null;
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="bg-gray-850 p-3 rounded-md shadow-sm flex flex-col"
>
<h3 className="font-bold text-md text-white">{country.country}</h3>
<p className="text-xs text-gray-400 mb-2">
{country.capital} / {country.tzid}
</p>
{display && <p className="text-sm text-gray-300 leading-snug">{display}</p>}
</motion.div>
);
};
// ---------- App ----------
const App: React.FC = () => {
const [scrubOffset, setScrubOffset] = useState(0);
// Selected language for summaries (en/de/jp). Detect default on mount.
const [lang, setLang] = useState<string>(() => detectDefaultLang());
// Controls visibility of burger menu
const [menuOpen, setMenuOpen] = useState(false);
// New grouped sections state
const [sections, setSections] = useState<NowGroup[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Globe drag helpers
const startScrubRef = useRef<number>(0);
const ROTATE_EPS = 0.0015; // radians (~0.086° ≈ ~20s)
// (legacy ID-first flow removed; we now use grouped sections + lazy load + quarter-hour refresh)
// update logic when user rotates the globe
const handleManualRotate = (newScrubOffset: number, isDragEnd: boolean) => {
setScrubOffset(newScrubOffset);
if (isDragEnd) {
const movedEnough = Math.abs(newScrubOffset - startScrubRef.current) > ROTATE_EPS;
if (movedEnough) {
// Reset sections for the new 'at' and fetch the first page
setSections([]);
setNextCursor(null);
fetchGroups({ reset: true });
}
}
};
const daysScrubbed = Math.floor(-scrubOffset / (2 * Math.PI));
// Build 'at' from scrub offset (same math as before)
function atFromScrubOffset(): Date {
const now = new Date();
const deltaHours = (scrubOffset / DEG2RAD) / 15;
return new Date(now.getTime() + deltaHours * 3600 * 1000);
}
async function fetchGroups({ reset, cursor }: { reset?: boolean; cursor?: string } = {}) {
if (isLoading) return;
setIsLoading(true);
try {
const params = new URLSearchParams();
const at = atFromScrubOffset();
params.set("at", at.toISOString());
params.set("page_size", "6"); // groups per page; tweak as you like
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/now-groups?${params.toString()}`);
if (!res.ok) return;
const data: NowGroupsResponse = await res.json();
setSections(prev => {
const base = reset ? [] : prev;
// De-duplicate by label if re-fetch overlaps
const existing = new Set(base.map(g => g.label));
const appended = data.groups.filter(g => !existing.has(g.label));
return [...base, ...appended];
});
setNextCursor(data.next_cursor);
} catch (e) {
console.error("fetchGroups failed", e);
} finally {
setIsLoading(false);
}
}
// Initial load + quarter-hour aligned refresh
useEffect(() => {
let timeoutId: number;
let intervalId: number;
const prime = async () => {
// first page
await fetchGroups({ reset: true });
// schedule next quarter tick
const ms = msUntilNextQuarter();
timeoutId = window.setTimeout(() => {
// on tick: reset and refetch
fetchGroups({ reset: true });
// then every 15 minutes
intervalId = window.setInterval(() => {
fetchGroups({ reset: true });
}, 15 * 60 * 1000);
}, Math.max(500, ms)); // minimum small delay to avoid 0ms storm
};
prime();
return () => {
if (timeoutId) clearTimeout(timeoutId);
if (intervalId) clearInterval(intervalId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loaderRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = loaderRef.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
const first = entries[0];
if (first.isIntersecting && nextCursor && !isLoading) {
fetchGroups({ cursor: nextCursor });
}
}, { root: null, rootMargin: "600px", threshold: 0 });
io.observe(el);
return () => io.disconnect();
}, [nextCursor, isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="w-full min-h-screen flex flex-col font-helvetica bg-black text-white">
<div className="w-full relative pt-24 mx-auto max-w-[1100px] px-4">
<div className="absolute top-5 left-0 right-0 text-white p-2">
<h1 className="text-5xl font-bold tracking-tighter text-center">10AM</h1>
{/* Burger pinned within the 1100px container */}
<button
onClick={() => setMenuOpen((prev) => !prev)}
aria-label="Open menu"
className="absolute right-0 top-1.5 text-white text-2xl focus:outline-none"
>
</button>
</div>
{/* Subtitle (centered, within container) */}
<div className="absolute top-16 left-0 right-0 text-center text-white p-2">
<p className="text-sm font-mono">
{daysScrubbed === 0 ? 'Today' : daysScrubbed === 1 ? 'Yesterday' : `${daysScrubbed} days ago`}
</p>
</div>
{/* Give Canvas a concrete height; it will fill this container */}
<div className="w-full h-[30vh] md:h-[30vh]">
<Canvas className="!w-full !h-full" camera={{ position: [0, 0, 6], fov: 40 }}>
<ambientLight intensity={0.1} />
<directionalLight position={[5, 0, 6]} intensity={1.5} color="#fffde7" />
<Globe onManualRotate={handleManualRotate} onDragStart={(s) => { startScrubRef.current = s; }} />
</Canvas>
</div>
</div>
{/* Language selection overlay (burger menu) */}
{menuOpen && (
<div className="fixed inset-0 z-50" aria-modal="true" role="dialog">
{/* dim background */}
<div className="absolute inset-0 bg-black/30" onClick={() => setMenuOpen(false)} />
{/* Panel: top dropdown on small screens; right sidebar on md+ */}
<div
className="
absolute left-0 right-0 top-0
md:left-auto md:right-0
w-full md:w-64
md:h-full
bg-neutral-900 text-neutral-50
p-4 shadow-lg
rounded-b-2xl md:rounded-none
"
>
<h2 className="font-semibold text-lg mb-3">Language</h2>
<button
className={`block w-full text-left px-2 py-2 rounded ${lang === 'en' ? 'bg-neutral-800' : 'hover:bg-neutral-800'}`}
onClick={() => {
setLang('en');
setCookie('lang', 'en');
setMenuOpen(false);
}}
>
English
</button>
<button
className={`block w-full text-left px-2 py-2 rounded mt-1 ${lang === 'de' ? 'bg-neutral-800' : 'hover:bg-neutral-800'}`}
onClick={() => {
setLang('de');
setCookie('lang', 'de');
setMenuOpen(false);
}}
>
Deutsch
</button>
<button
className={`block w-full text-left px-2 py-2 rounded mt-1 ${lang === 'jp' ? 'bg-neutral-800' : 'hover:bg-neutral-800'}`}
onClick={() => {
setLang('jp');
setCookie('lang', 'jp');
setMenuOpen(false);
}}
>
</button>
</div>
</div>
)}
<div className="w-full mx-auto max-w-[1100px] px-4 pb-8">
{sections.map((group) => (
<div key={group.label} className="mb-6">
<div className="sticky top-0 z-10 backdrop-blur-sm/0">
<h2 className="text-lg font-semibold text-gray-200 mb-2">{group.label}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
<AnimatePresence initial={false}>
{group.results.map((country) => (
<CountryCard key={`${group.label}-${country.country}-${country.capital}`} country={country} lang={lang} />
))}
</AnimatePresence>
</div>
</div>
))}
{/* Sentinel for infinite scroll */}
<div ref={loaderRef} className="w-full h-12 flex items-center justify-center text-gray-400 text-sm">
{isLoading ? "Loading…" : (nextCursor ? "Scroll for more…" : "End of list")}
</div>
</div>
</div>
);
};
export default App;

38
frontend/src/index.css Normal file
View File

@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: 'Helvetica Neue', Helvetica, Arial, system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: #000000;
color: #ffffff;
overflow-x: hidden;
}
#root {
width: 100%;
height: 100vh;
}
}
@layer utilities {
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.backdrop-blur-subtle {
backdrop-filter: blur(2px);
}
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
'helvetica': ['Helvetica Neue', 'Helvetica', 'Arial', 'system-ui', '-apple-system', 'sans-serif'],
},
colors: {
'gray-850': '#1a1a1a',
}
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

23
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// inside export default defineConfig({ ... })
server: {
proxy: {
'/ten-am': 'http://127.0.0.1:8000',
'/ten-am-id': 'http://127.0.0.1:8000',
'/state': 'http://127.0.0.1:8000',
// NEW: grouped endpoint(s)
'/now-groups': 'http://127.0.0.1:8000',
'/api/now-groups': 'http://127.0.0.1:8000',
}
},
// Optional: keep this if you do "npm run build" to ship static files with Python
build: {
outDir: '../server/static',
emptyOutDir: true,
},
})