initial commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
frontend/public/earth-specular.jpg
Normal file
7
frontend/public/earth-specular.jpg
Normal 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>
|
||||
BIN
frontend/public/world-map.png
Normal file
BIN
frontend/public/world-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 692 KiB |
477
frontend/src/App.tsx
Normal file
477
frontend/src/App.tsx
Normal 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
38
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
18
frontend/tailwind.config.js
Normal file
18
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
23
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user