commit fde38516b49802ba7d51ae9a9cb115dd05415642 Author: chrisf Date: Wed Sep 30 20:06:15 2026 +0000 Flight lookup: OpenSky live data, i18n, PWA, visual redesign diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9906b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +credentials.json +.gitea-token +node_modules/ +server.log +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..cecbee4 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Flight Lookup + +A simple static webpage that takes a flight number and airline as input and displays the flight's origin and destination. + +## Files + +- `index.html` — page markup +- `style.css` — styling +- `data.js` — mock data source (airlines, airports, flights) +- `app.js` — form handling and result rendering + +## Usage + +Open `index.html` in a browser, or serve the directory: + +```sh +python3 -m http.server 8000 +``` + +Then visit http://localhost:8000. + +## Example flights + +| Airline | Flight | Route | +|---------|--------|-------| +| American (AA) | 100 | JFK → LAX | +| Delta (DL) | 123 | ATL → SFO | +| United (UA) | 789 | ORD → LHR | +| British Airways (BA) | 117 | LHR → JFK | +| Lufthansa (LH) | 400 | FRA → JFK | + +## Data source + +Flight data is mocked in `data.js`. To use a real source (e.g. FlightAware AeroAPI), replace the `lookupFlight` function with an `fetch` call to the API. diff --git a/app.js b/app.js new file mode 100644 index 0000000..8aec155 --- /dev/null +++ b/app.js @@ -0,0 +1,473 @@ +/* ============ Flight Lookup App ============ */ + +const HISTORY_KEY = "flight-lookup-history"; +const THEME_KEY = "flight-lookup-theme"; +const LANG_KEY = "flight-lookup-lang"; +const MAX_HISTORY = 8; +const DEBOUNCE_MS = 250; + +// ---- DOM refs ---- +const airlineInput = document.getElementById("airline"); +const flightInput = document.getElementById("flight-number"); +const form = document.getElementById("lookup-form"); +const suggestionsBox = document.getElementById("airline-suggestions"); +const resultBox = document.getElementById("result"); +const historyList = document.getElementById("history-list"); +const historySection = document.getElementById("history-section"); +const langSelect = document.getElementById("lang"); +const themeToggle = document.getElementById("theme-toggle"); + +let lastFlight = null; +let debounceTimer = null; +let activeSuggestion = -1; +let suggestionItems = []; + +// ============ Theme ============ +function initTheme() { + const saved = localStorage.getItem(THEME_KEY); + const prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches; + const theme = saved || (prefersLight ? "light" : "dark"); + document.documentElement.setAttribute("data-theme", theme); +} + +function toggleTheme() { + const current = document.documentElement.getAttribute("data-theme") || "dark"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem(THEME_KEY, next); +} + +// ============ Language ============ +function initLang() { + const saved = localStorage.getItem(LANG_KEY); + if (saved && SUPPORTED_LANGS.includes(saved)) { + setLang(saved); + } else { + setLang("en"); + } + langSelect.value = currentLang; +} + +function onLangChange() { + const lang = langSelect.value; + localStorage.setItem(LANG_KEY, lang); + setLang(lang); + applyTranslations(); + if (lastFlight) { + renderResult(lastFlight); + } +} + +// ============ History ============ +function getHistory() { + try { + return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; + } catch { + return []; + } +} + +function saveHistory(entry) { + let history = getHistory(); + history = history.filter((h) => h.code !== entry.code); + history.unshift(entry); + history = history.slice(0, MAX_HISTORY); + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + renderHistory(); +} + +function renderHistory() { + const history = getHistory(); + if (!history.length) { + historySection.classList.add("hidden"); + return; + } + historySection.classList.remove("hidden"); + historyList.innerHTML = ""; + for (const entry of history) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.className = "history-item"; + btn.type = "button"; + const route = entry.from && entry.to ? ` ${entry.from} \u2192 ${entry.to}` : ""; + btn.innerHTML = `${entry.code}${route}`; + btn.addEventListener("click", () => { + airlineInput.value = entry.airlineCode; + flightInput.value = entry.flightNumber; + doLookup(entry.airlineCode, entry.flightNumber); + }); + li.appendChild(btn); + historyList.appendChild(li); + } +} + +// ============ Airline suggestions ============ +function showSuggestions(query) { + activeSuggestion = -1; + if (!query || query.length < 1) { + hideSuggestions(); + return; + } + const matches = searchAirlines(query); + if (!matches.length) { + hideSuggestions(); + return; + } + suggestionItems = matches; + suggestionsBox.innerHTML = ""; + matches.forEach((airline, i) => { + const div = document.createElement("div"); + div.className = "suggestion"; + div.dataset.index = i; + div.innerHTML = `${airline.code} ${airline.name}`; + div.addEventListener("mousedown", (e) => { + e.preventDefault(); + selectSuggestion(airline); + }); + suggestionsBox.appendChild(div); + }); + suggestionsBox.classList.remove("hidden"); +} + +function hideSuggestions() { + suggestionsBox.classList.add("hidden"); + suggestionItems = []; + activeSuggestion = -1; +} + +function selectSuggestion(airline) { + airlineInput.value = airline.code; + hideSuggestions(); + flightInput.focus(); +} + +function moveSuggestion(delta) { + if (!suggestionItems.length) return; + activeSuggestion = (activeSuggestion + delta + suggestionItems.length) % suggestionItems.length; + const items = suggestionsBox.querySelectorAll(".suggestion"); + items.forEach((el, i) => el.classList.toggle("active", i === activeSuggestion)); + if (items[activeSuggestion]) { + items[activeSuggestion].scrollIntoView({ block: "nearest" }); + } +} + +function commitSuggestion() { + if (activeSuggestion >= 0 && suggestionItems[activeSuggestion]) { + selectSuggestion(suggestionItems[activeSuggestion]); + } +} + +// ============ Lookup ============ +function doLookup(airlineCode, flightNumber) { + hideSuggestions(); + showLoading(); + lookupFlight(airlineCode, flightNumber) + .then((flight) => { + if (flight) { + lastFlight = flight; + renderResult(flight); + saveHistory({ + code: `${flight.airlineCode}${flight.flightNumber}`, + airlineCode: flight.airlineCode, + flightNumber: flight.flightNumber, + from: flight.origin ? flight.origin.code : null, + to: flight.destination ? flight.destination.code : null, + }); + } else { + showError(t("noFlightFound", { flight: `${airlineCode} ${flightNumber}` })); + } + }) + .catch(() => { + showError(t("networkError")); + }); +} + +function showLoading() { + resultBox.className = "result loading"; + resultBox.innerHTML = `
${t("searching")}`; +} + +function showError(message) { + lastFlight = null; + resultBox.className = "result error"; + resultBox.textContent = message; +} + +function renderResult(flight) { + const statusText = t(flight.status) || flight.status; + const shareUrl = `${location.origin}${location.pathname}?f=${flight.airlineCode}${flight.flightNumber}`; + const isLive = flight.live && flight.position; + + let body; + if (isLive) { + body = renderLiveBody(flight); + } else { + body = renderMockBody(flight); + } + + resultBox.className = "result" + (isLive ? " live" : ""); + resultBox.innerHTML = ` +
+ ${flight.airlineCode} ${flight.flightNumber} + ${statusText} + ${isLive ? `${t("live")}` : `${t("mock")}`} +
+ ${body} +
${renderMap(flight)}
+
+ ${flight.airline} + +
+ `; +} + +function renderMockBody(flight) { + const depTime = formatTime(new Date(flight.depTime)); + const arrTime = formatTime(new Date(flight.arrTime)); + const duration = formatDuration(Math.round((flight.arrTime - flight.depTime) / 60000)); + return ` +
+
+
${flight.origin.code}
+
${flight.origin.city}
+
${depTime}
+
${flight.origin.tz}
+
+
+
+
${duration}
+
+
+
${flight.destination.code}
+
${flight.destination.city}
+
${arrTime}
+
${flight.destination.tz}
+
+
+ `; +} + +function renderLiveBody(flight) { + const p = flight.position; + const seenAgo = formatAgo(flight.lastSeen); + const rows = [ + [t("altitude"), p.altitudeFt != null ? `${p.altitudeFt.toLocaleString()} ft` : "—"], + [t("speed"), p.velocityKt != null ? `${p.velocityKt} kt` : "—"], + [t("heading"), p.trackDeg != null ? `${p.trackDeg}°` : "—"], + [t("vertRate"), p.vertRateFpm != null ? `${p.vertRateFpm > 0 ? "+" : ""}${p.vertRateFpm} fpm` : "—"], + [t("position"), `${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}`], + [t("lastSeen"), seenAgo], + ]; + return ` +
+ ${rows + .map( + ([k, v]) => ` +
+ ${k} + ${v} +
` + ) + .join("")} +
+ `; +} + +// ============ Map rendering (equirectangular) ============ +function renderMap(flight) { + const W = 600; + const H = 260; + const PAD = 40; + + function project(lat, lon) { + const x = PAD + ((lon + 180) / 360) * (W - PAD * 2); + const y = PAD + ((90 - lat) / 180) * (H - PAD * 2); + return { x, y }; + } + + // Grid lines + let grid = ""; + for (let lon = -180; lon <= 180; lon += 30) { + const p1 = project(-90, lon); + const p2 = project(90, lon); + grid += ``; + } + for (let lat = -90; lat <= 90; lat += 30) { + const p1 = project(lat, -180); + const p2 = project(lat, 180); + grid += ``; + } + + const isLive = flight.live && flight.position; + + let content; + let aria; + if (isLive) { + const p = flight.position; + const pos = project(p.lat, p.lon); + // Heading arrow: a short line from the position in the direction of travel + const rad = ((p.trackDeg || 0) - 90) * (Math.PI / 180); + const len = 26; + const hx = pos.x + Math.cos(rad) * len; + const hy = pos.y + Math.sin(rad) * len; + aria = `${flight.airlineCode}${flight.flightNumber} at ${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}`; + content = ` + + + + ${flight.airlineCode}${flight.flightNumber} + ${p.altitudeFt != null ? p.altitudeFt.toLocaleString() + " ft" : ""} + `; + } else { + const from = project(flight.origin.lat, flight.origin.lon); + const to = project(flight.destination.lat, flight.destination.lon); + const midX = (from.x + to.x) / 2; + const midY = (from.y + to.y) / 2; + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const lift = Math.min(dist * 0.25, 60); + const ctrlX = midX - (dy / dist) * lift; + const ctrlY = midY + (dx / dist) * lift; + aria = `${flight.origin.code} to ${flight.destination.code}`; + content = ` + + + + ${flight.origin.code} + ${flight.origin.city} + ${flight.destination.code} + ${flight.destination.city} + `; + } + + return ` + + + + + + + + ${grid} + ${content} + + `; +} + +// ============ Formatting helpers ============ +function formatTime(date) { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatDuration(minutes) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + return `${h}h ${m}m`; +} + +function formatAgo(epochMs) { + if (!epochMs) return "—"; + const secs = Math.max(0, Math.round((Date.now() - epochMs) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ${mins % 60}m ago`; +} + +// ============ URL params ============ +function checkUrlParams() { + const params = new URLSearchParams(location.search); + const f = params.get("f"); + if (f) { + const decoded = decodeFlight(f); + if (decoded) { + airlineInput.value = decoded.airline; + flightInput.value = decoded.number; + doLookup(decoded.airline, decoded.number); + } + } +} + +// ============ Event wiring ============ +function onAirlineInput() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + showSuggestions(airlineInput.value.trim()); + }, DEBOUNCE_MS); +} + +function onAirlineKeydown(e) { + if (suggestionsBox.classList.contains("hidden")) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + moveSuggestion(1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + moveSuggestion(-1); + } else if (e.key === "Enter") { + e.preventDefault(); + commitSuggestion(); + } else if (e.key === "Escape") { + hideSuggestions(); + } +} + +function onFlightKeydown(e) { + if (e.key === "Enter") { + e.preventDefault(); + submitForm(); + } +} + +function submitForm() { + const airline = airlineInput.value.trim().toUpperCase(); + const flight = flightInput.value.trim(); + if (!airline || !flight) return; + doLookup(airline, flight); +} + +function onFormSubmit(e) { + e.preventDefault(); + submitForm(); +} + +function onDocumentClick(e) { + if (!e.target.closest(".field")) { + hideSuggestions(); + } +} + +// ============ Service worker ============ +function registerServiceWorker() { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("sw.js").catch(() => { + // SW registration failed (e.g. file:// or unsupported) — non-fatal + }); + } +} + +// ============ Init ============ +function init() { + initTheme(); + initLang(); + renderHistory(); + checkUrlParams(); + + form.addEventListener("submit", onFormSubmit); + airlineInput.addEventListener("input", onAirlineInput); + airlineInput.addEventListener("keydown", onAirlineKeydown); + flightInput.addEventListener("keydown", onFlightKeydown); + document.addEventListener("click", onDocumentClick); + langSelect.addEventListener("change", onLangChange); + themeToggle.addEventListener("click", toggleTheme); + + registerServiceWorker(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/data.js b/data.js new file mode 100644 index 0000000..5cc57a6 --- /dev/null +++ b/data.js @@ -0,0 +1,318 @@ +// Mock flight data source. +// In production this would be replaced by an API call (e.g. FlightAware AeroAPI). + +const AIRLINES = { + AA: { name: "American Airlines", iata: "AA" }, + DL: { name: "Delta Air Lines", iata: "DL" }, + UA: { name: "United Airlines", iata: "UA" }, + BA: { name: "British Airways", iata: "BA" }, + LH: { name: "Lufthansa", iata: "LH" }, + AF: { name: "Air France", iata: "AF" }, + JL: { name: "Japan Airlines", iata: "JL" }, + QF: { name: "Qantas", iata: "QF" }, +}; + +const AIRPORTS = { + JFK: { city: "New York", country: "USA", lat: 40.6413, lon: -73.7781, tz: "America/New_York" }, + LAX: { city: "Los Angeles", country: "USA", lat: 33.9416, lon: -118.4085, tz: "America/Los_Angeles" }, + ORD: { city: "Chicago", country: "USA", lat: 41.9742, lon: -87.9073, tz: "America/Chicago" }, + SFO: { city: "San Francisco", country: "USA", lat: 37.6213, lon: -122.379, tz: "America/Los_Angeles" }, + ATL: { city: "Atlanta", country: "USA", lat: 33.6407, lon: -84.4277, tz: "America/New_York" }, + LHR: { city: "London", country: "UK", lat: 51.47, lon: -0.4543, tz: "Europe/London" }, + CDG: { city: "Paris", country: "France", lat: 49.0097, lon: 2.5479, tz: "Europe/Paris" }, + FRA: { city: "Frankfurt", country: "Germany", lat: 50.0333, lon: 8.5706, tz: "Europe/Berlin" }, + NRT: { city: "Tokyo", country: "Japan", lat: 35.772, lon: 140.3929, tz: "Asia/Tokyo" }, + SYD: { city: "Sydney", country: "Australia", lat: -33.9399, lon: 151.1753, tz: "Australia/Sydney" }, +}; + +// key: `${airlineCode}-${flightNumber}` +const FLIGHTS = { + "AA-100": { origin: "JFK", destination: "LAX", status: "on-time", depOffset: 45, durationMin: 330 }, + "AA-200": { origin: "LAX", destination: "JFK", status: "delayed", depOffset: 120, durationMin: 345, delayMin: 40 }, + "AA-300": { origin: "ORD", destination: "ATL", status: "on-time", depOffset: 90, durationMin: 110 }, + "DL-123": { origin: "ATL", destination: "SFO", status: "on-time", depOffset: 30, durationMin: 240 }, + "DL-456": { origin: "SFO", destination: "ATL", status: "cancelled", depOffset: 60, durationMin: 250 }, + "UA-789": { origin: "ORD", destination: "LHR", status: "delayed", depOffset: 180, durationMin: 420, delayMin: 25 }, + "UA-101": { origin: "LHR", destination: "ORD", status: "on-time", depOffset: 200, durationMin: 430 }, + "BA-117": { origin: "LHR", destination: "JFK", status: "on-time", depOffset: 15, durationMin: 420 }, + "BA-118": { origin: "JFK", destination: "LHR", status: "on-time", depOffset: 75, durationMin: 435 }, + "LH-400": { origin: "FRA", destination: "JFK", status: "delayed", depOffset: 100, durationMin: 440, delayMin: 55 }, + "LH-401": { origin: "JFK", destination: "FRA", status: "on-time", depOffset: 140, durationMin: 450 }, + "LH-700": { origin: "FRA", destination: "NRT", status: "on-time", depOffset: 220, durationMin: 720 }, + "AF-001": { origin: "CDG", destination: "JFK", status: "on-time", depOffset: 55, durationMin: 430 }, + "JL-003": { origin: "NRT", destination: "LAX", status: "delayed", depOffset: 260, durationMin: 480, delayMin: 35 }, + "QF-010": { origin: "SYD", destination: "LAX", status: "on-time", depOffset: 300, durationMin: 470 }, +}; + +// --- i18n --- + +const I18N = { + en: { + title: "Flight Lookup", + subtitle: "Enter an airline and flight number to see its route.", + airline: "Airline", + airlinePlaceholder: "e.g. American, BA, Lufthansa…", + flightNumber: "Flight number", + flightNumberPlaceholder: "e.g. 100", + lookup: "Look up flight", + onTime: "On time", + delayed: "Delayed", + cancelled: "Cancelled", + departs: "Departs", + arrives: "Arrives", + copyLink: "Copy link", + copied: "Copied!", + recentLookups: "Recent lookups", + noFlightFound: "No flight found for {flight}. Try e.g. AA 100 or BA 117.", + multipleAirlines: 'Multiple airlines match "{query}". Please select one from the list.', + noAirlineFound: 'No airline found matching "{query}".', + enterFlightNumber: "Please enter a flight number.", + searching: "Searching…", + networkError: "Network error. Please try again.", + themeToggle: "Toggle theme", + language: "Language", + live: "LIVE", + mock: "SAMPLE", + airborne: "Airborne", + onGround: "On ground", + altitude: "Altitude", + speed: "Speed", + heading: "Heading", + vertRate: "Vert. rate", + position: "Position", + lastSeen: "Last seen", + }, + fr: { + title: "Recherche de vol", + subtitle: "Saisissez une compagnie et un numéro de vol pour voir son itinéraire.", + airline: "Compagnie", + airlinePlaceholder: "ex. American, BA, Lufthansa…", + flightNumber: "Numéro de vol", + flightNumberPlaceholder: "ex. 100", + lookup: "Rechercher le vol", + onTime: "À l'heure", + delayed: "Retardé", + cancelled: "Annulé", + departs: "Départ", + arrives: "Arrivée", + copyLink: "Copier le lien", + copied: "Copié !", + recentLookups: "Recherches récentes", + noFlightFound: "Aucun vol trouvé pour {flight}. Essayez p. ex. AA 100 ou BA 117.", + multipleAirlines: 'Plusieurs compagnies correspondent à « {query} ». Veuillez en sélectionner une.', + noAirlineFound: 'Aucune compagnie trouvée pour « {query} ».', + enterFlightNumber: "Veuillez saisir un numéro de vol.", + searching: "Recherche…", + networkError: "Erreur réseau. Veuillez réessayer.", + themeToggle: "Changer de thème", + language: "Langue", + live: "EN DIRECT", + mock: "EXEMPLE", + airborne: "En vol", + onGround: "Au sol", + altitude: "Altitude", + speed: "Vitesse", + heading: "Cap", + vertRate: "Vitesse vert.", + position: "Position", + lastSeen: "Vu il y a", + }, + de: { + title: "Flugsuche", + subtitle: "Geben Sie eine Airline und eine Flugnummer ein, um die Route zu sehen.", + airline: "Airline", + airlinePlaceholder: "z. B. American, BA, Lufthansa…", + flightNumber: "Flugnummer", + flightNumberPlaceholder: "z. B. 100", + lookup: "Flug suchen", + onTime: "Pünktlich", + delayed: "Verspätet", + cancelled: "Storniert", + departs: "Abflug", + arrives: "Ankunft", + copyLink: "Link kopieren", + copied: "Kopiert!", + recentLookups: "Letzte Suchen", + noFlightFound: "Kein Flug für {flight} gefunden. Z. B. AA 100 oder BA 117.", + multipleAirlines: 'Mehrere Airlines passen auf „{query}". Bitte eine auswählen.', + noAirlineFound: 'Keine Airline gefunden für „{query}"', + enterFlightNumber: "Bitte geben Sie eine Flugnummer ein.", + searching: "Suche…", + networkError: "Netzwerkfehler. Bitte erneut versuchen.", + themeToggle: "Theme wechseln", + language: "Sprache", + live: "LIVE", + mock: "BEISPIEL", + airborne: "In der Luft", + onGround: "Am Boden", + altitude: "Höhe", + speed: "Geschwindigkeit", + heading: "Richtung", + vertRate: "Vertikalrate", + position: "Position", + lastSeen: "Zuletzt gesehen", + }, + ja: { + title: "フライト検索", + subtitle: "航空会社とフライト番号を入力してルートを確認します。", + airline: "航空会社", + airlinePlaceholder: "例: American, BA, Lufthansa…", + flightNumber: "フライト番号", + flightNumberPlaceholder: "例: 100", + lookup: "フライトを検索", + onTime: "定時", + delayed: "遅延", + cancelled: "欠航", + departs: "出発", + arrives: "到着", + copyLink: "リンクをコピー", + copied: "コピーしました!", + recentLookups: "最近の検索", + noFlightFound: "{flight} のフライトが見つかりません。例: AA 100 や BA 117。", + multipleAirlines: '「{query}」に複数の航空会社があります。一覧から選択してください。', + noAirlineFound: '「{query}」に一致する航空会社が見つかりません。', + enterFlightNumber: "フライト番号を入力してください。", + searching: "検索中…", + networkError: "ネットワークエラー。もう一度お試しください。", + themeToggle: "テーマ切替", + language: "言語", + live: "ライブ", + mock: "サンプル", + airborne: "飛行中", + onGround: "地上", + altitude: "高度", + speed: "速度", + heading: "針路", + vertRate: "垂直速度", + position: "位置", + lastSeen: "最終確認", + }, +}; + +const SUPPORTED_LANGS = ["en", "fr", "de", "ja"]; + +let currentLang = "en"; + +function t(key, params) { + const dict = I18N[currentLang] || I18N.en; + let str = dict[key] || I18N.en[key] || key; + if (params) { + for (const [k, v] of Object.entries(params)) { + str = str.replace(`{${k}}`, v); + } + } + return str; +} + +function setLang(lang) { + if (!SUPPORTED_LANGS.includes(lang)) lang = "en"; + currentLang = lang; + document.documentElement.lang = lang; + applyTranslations(); +} + +function applyTranslations() { + document.title = t("title"); + document.querySelector("h1").textContent = t("title"); + document.querySelector(".subtitle").textContent = t("subtitle"); + document.querySelector('[for="airline"]').textContent = t("airline"); + airlineInput.placeholder = t("airlinePlaceholder"); + document.querySelector('[for="flight-number"]').textContent = t("flightNumber"); + flightInput.placeholder = t("flightNumberPlaceholder"); + document.querySelector('button[type="submit"]').textContent = t("lookup"); + const histHeading = document.querySelector("#history-section h2"); + if (histHeading) histHeading.textContent = t("recentLookups"); + const themeBtn = document.querySelector("#theme-toggle"); + if (themeBtn) themeBtn.title = t("themeToggle"); + const langLabel = document.querySelector('[for="lang"]'); + if (langLabel) langLabel.textContent = t("language"); + // Re-render result if visible + if (lastFlight && !resultBox.classList.contains("hidden")) { + renderResult(lastFlight); + } +} + +// --- Lookup (async to simulate network) --- + +// Look up a flight. Tries the live OpenSky proxy first; if the flight has +// no live position (or the proxy is unavailable), falls back to the mock +// dataset so the UI always has something to show. +async function lookupFlight(airlineCode, flightNumber) { + // 1) Try live data via the Node proxy + try { + const r = await fetch( + `/api/lookup?code=${encodeURIComponent(airlineCode)}&num=${encodeURIComponent(flightNumber)}` + ); + if (r.ok) { + const live = await r.json(); + if (live.live) { + // Enrich with airline name + airport info when we can + const airlineName = AIRLINES[airlineCode] ? AIRLINES[airlineCode].name : airlineCode; + return { ...live, airline: airlineName, live: true }; + } + // live === false → not airborne or OpenSky error. Fall through to mock. + } + } catch (e) { + // Proxy unreachable (e.g. opened via file://) — fall through to mock. + } + + // 2) Fallback: mock dataset + const key = `${airlineCode}-${flightNumber}`; + const flight = FLIGHTS[key]; + if (!flight) return null; + const now = Date.now(); + const depEpoch = now + flight.depOffset * 60000; + const arrEpoch = depEpoch + flight.durationMin * 60000; + return { + airline: AIRLINES[airlineCode].name, + airlineCode, + flightNumber, + status: flight.status, + delayMin: flight.delayMin || 0, + origin: { code: flight.origin, ...AIRPORTS[flight.origin] }, + destination: { code: flight.destination, ...AIRPORTS[flight.destination] }, + depTime: depEpoch, + arrTime: arrEpoch, + live: false, + }; +} + +// Fuzzy airline search +function searchAirlines(query) { + const q = query.trim().toLowerCase(); + if (!q) return Object.entries(AIRLINES).map(([code, a]) => ({ code, name: a.name })); + + const scored = Object.entries(AIRLINES).map(([code, a]) => { + const name = a.name.toLowerCase(); + let score = 0; + if (code.toLowerCase() === q) score = 100; + else if (code.toLowerCase().startsWith(q)) score = 80; + else if (name.startsWith(q)) score = 70; + else if (name.includes(q)) score = 50; + else { + let idx = 0; + for (const ch of name) { + if (ch === q[idx]) idx++; + if (idx === q.length) break; + } + if (idx === q.length) score = 30; + } + return { code, name: a.name, score }; + }).filter((a) => a.score > 0); + + scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + return scored.map(({ code, name }) => ({ code, name })); +} + +function encodeFlight(airlineCode, flightNumber) { + return `?f=${encodeURIComponent(airlineCode)}${encodeURIComponent(flightNumber)}`; +} + +function decodeFlight(url) { + const params = new URLSearchParams(url.search); + const f = params.get("f"); + if (!f) return null; + const match = f.match(/^([A-Z]{2,3})(\d+)$/i); + if (!match) return null; + return { airline: match[1].toUpperCase(), number: match[2] }; +} diff --git a/icon-192.png b/icon-192.png new file mode 100644 index 0000000..bab08fc Binary files /dev/null and b/icon-192.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..095c69d --- /dev/null +++ b/index.html @@ -0,0 +1,104 @@ + + + + + + + + + + + + Flight Lookup + + + + + + + + + +
+
+
+ +
+ + +
+
+
+ + +
+ +
+ + + + +
+ +
+ Mock data · no real flight info +
+ + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6cd51ed --- /dev/null +++ b/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Flight Lookup", + "short_name": "Flights", + "description": "Look up flight status, route, and times by airline and flight number.", + "start_url": ".", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "orientation": "portrait", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..e7d4e52 --- /dev/null +++ b/server.js @@ -0,0 +1,207 @@ +// Flight Lookup — Node proxy + static server +// Serves the static site and proxies lookups to the OpenSky Network API. +// OpenSky gives live ADS-B positions (no scheduled status), so a lookup +// returns the flight's current position if it's airborne, otherwise a +// "not airborne" result. Mock data is used as a fallback when OpenSky +// has no live match for the callsign. + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 9000; +const ROOT = __dirname; + +// ---- OpenSky credentials (from credentials.json) ---- +let OPENSKY_USER = ""; +let OPENSKY_PASS = ""; +try { + const creds = JSON.parse(fs.readFileSync(path.join(ROOT, "credentials.json"), "utf8")); + OPENSKY_USER = creds.clientId || ""; + OPENSKY_PASS = creds.clientSecret || ""; +} catch (e) { + console.warn("Could not read credentials.json — OpenSky lookups will use anonymous access."); +} + +const OPENSKY_BASE = "https://opensky-network.org"; + +// ---- MIME types for static files ---- +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webmanifest": "application/manifest+json", +}; + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(body); +} + +function serveStatic(req, res, urlPath) { + let filePath = urlPath === "/" ? "/index.html" : urlPath; + filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ""); + const full = path.join(ROOT, filePath); + if (!full.startsWith(ROOT)) { + res.writeHead(403); + return res.end("Forbidden"); + } + fs.readFile(full, (err, data) => { + if (err) { + res.writeHead(404, { "Content-Type": "text/plain" }); + return res.end("Not found"); + } + const ext = path.extname(full).toLowerCase(); + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" }); + res.end(data); + }); +} + +// ---- OpenSky call ---- +function openSkyFetch(pathname) { + const auth = Buffer.from(`${OPENSKY_USER}:${OPENSKY_PASS}`).toString("base64"); + const headers = { "User-Agent": "flight-lookup/1.0 (personal project)" }; + if (OPENSKY_USER) headers["Authorization"] = "Basic " + auth; + return fetch(OPENSKY_BASE + pathname, { headers }); +} + +// OpenSky states/all returns an array of arrays. Index map: +// 0 0: icao24, 1: callsign, 2: origin_country, 3: time_position, 4: last_seen, +// 5: longitude, 6: latitude, 7: baro_altitude, 8: on_ground, 9: velocity, +// 10: true_track, 11: vertical_rate, 12: sensors, 13: geo_altitude, +// 14: squawk, 15: spi, 16: position_source + +function normalizeState(s) { + return { + icao24: s[0], + callsign: (s[1] || "").trim(), + country: s[2], + lastSeen: s[4], + lon: s[5], + lat: s[6], + baroAlt: s[7], + onGround: s[8], + velocity: s[9], + track: s[10], + vertRate: s[11], + geoAlt: s[13], + squawk: s[14], + }; +} + +// Find a live state matching the callsign. OpenSky callsigns are the +// flight number (e.g. "AA100"). We match on the numeric flight number and +// prefer an exact callsign match. +function findFlight(states, airlineCode, flightNumber) { + const num = String(flightNumber); + let exact = null; + let numeric = null; + for (const s of states) { + const cs = s.callsign.toUpperCase(); + if (!cs) continue; + // Exact: callsign equals "AA100" + if (cs === `${airlineCode}${num}`) { + exact = s; + break; + } + // Numeric: callsign ends with the flight number (e.g. "AAL100", "100") + if (!numeric && cs.endsWith(num) && cs.length >= num.length) { + numeric = s; + } + } + return exact || numeric; +} + +async function handleLookup(req, res, query) { + const code = (query.get("code") || "").toUpperCase(); + const num = (query.get("num") || "").trim(); + if (!code || !num) { + return sendJson(res, 400, { error: "code and num query params required" }); + } + + let states = []; + let source = "opensky"; + let openSkyError = null; + try { + const r = await openSkyFetch("/api/states/all"); + if (!r.ok) { + openSkyError = `OpenSky HTTP ${r.status}`; + } else { + const data = await r.json(); + states = (data.states || []).map(normalizeState).filter((s) => s.callsign); + } + } catch (e) { + openSkyError = String(e.message || e); + } + + const match = states.length ? findFlight(states, code, num) : null; + + if (match) { + const now = Date.now(); + const lastSeenMs = match.lastSeen ? match.lastSeen * 1000 : now; + const altitudeFt = match.geoAlt != null ? Math.round(match.geoAlt * 3.28084) : null; + return sendJson(res, 200, { + source: "opensky", + live: true, + airlineCode: code, + flightNumber: num, + callsign: match.callsign, + country: match.country, + status: match.onGround ? "onGround" : "airborne", + position: { + lat: match.lat, + lon: match.lon, + altitudeFt, + velocityKt: match.velocity != null ? Math.round(match.velocity * 1.94384) : null, + trackDeg: match.track != null ? Math.round(match.track) : null, + vertRateFpm: match.vertRate != null ? Math.round(match.vertRate * 196.85) : null, + }, + lastSeen: lastSeenMs, + squawk: match.squawk, + icao24: match.icao24, + }); + } + + // No live match — return a "not airborne" result so the UI can show + // a friendly message (and optionally fall back to mock). + return sendJson(res, 200, { + source: openSkyError ? "none" : "opensky", + live: false, + airlineCode: code, + flightNumber: num, + error: openSkyError || "notAirborne", + message: openSkyError + ? `OpenSky unavailable: ${openSkyError}` + : "No live position found for this flight right now.", + }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + + if (url.pathname === "/api/lookup") { + handleLookup(req, res, url.searchParams).catch((e) => { + sendJson(res, 500, { error: String(e.message || e) }); + }); + return; + } + + if (url.pathname === "/api/health") { + return sendJson(res, 200, { ok: true, opensky: Boolean(OPENSKY_USER) }); + } + + serveStatic(req, res, url.pathname); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`Flight Lookup running at http://0.0.0.0:${PORT}`); + console.log(`OpenSky auth: ${OPENSKY_USER ? "configured" : "anonymous"}`); +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..225d0d4 --- /dev/null +++ b/style.css @@ -0,0 +1,814 @@ +/* ============ Theme variables ============ */ +:root { + --bg: #0f172a; + --bg-soft: #131c31; + --card: rgba(30, 41, 59, 0.72); + --card-solid: #1e293b; + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #38bdf8; + --accent-2: #818cf8; + --accent-hover: #0ea5e9; + --error: #f87171; + --success: #4ade80; + --warning: #fbbf24; + --border: #334155; + --border-soft: rgba(148, 163, 184, 0.18); + --grid-line: rgba(148, 163, 184, 0.06); + --glow-1: rgba(56, 189, 248, 0.16); + --glow-2: rgba(129, 140, 248, 0.14); + --shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + --map-bg: #0c1425; + --map-grid: #1e293b; + --map-text: #e2e8f0; + --input-bg: rgba(15, 23, 42, 0.6); +} + +[data-theme="light"] { + --bg: #f1f5f9; + --bg-soft: #e8eef5; + --card: rgba(255, 255, 255, 0.82); + --card-solid: #ffffff; + --text: #0f172a; + --muted: #64748b; + --accent: #0284c7; + --accent-2: #6366f1; + --accent-hover: #0369a1; + --error: #dc2626; + --success: #16a34a; + --warning: #d97706; + --border: #cbd5e1; + --border-soft: rgba(100, 116, 139, 0.22); + --grid-line: rgba(100, 116, 139, 0.08); + --glow-1: rgba(2, 132, 199, 0.12); + --glow-2: rgba(99, 102, 241, 0.1); + --shadow: 0 18px 50px rgba(15, 23, 42, 0.12); + --map-bg: #e2e8f0; + --map-grid: #cbd5e1; + --map-text: #0f172a; + --input-bg: rgba(255, 255, 255, 0.7); +} + +/* ============ Base ============ */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +[hidden] { + display: none !important; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem 1rem; + position: relative; + overflow-x: hidden; + transition: background 0.3s ease, color 0.3s ease; +} + +/* ============ Background decoration ============ */ +.bg-grid { + position: fixed; + inset: 0; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 48px 48px; + pointer-events: none; + z-index: 0; +} + +.bg-glow { + position: fixed; + width: 520px; + height: 520px; + border-radius: 50%; + filter: blur(90px); + pointer-events: none; + z-index: 0; + animation: drift 18s ease-in-out infinite alternate; +} + +.bg-glow-1 { + top: -160px; + left: -120px; + background: var(--glow-1); +} + +.bg-glow-2 { + bottom: -180px; + right: -140px; + background: var(--glow-2); + animation-delay: -9s; +} + +@keyframes drift { + from { transform: translate(0, 0) scale(1); } + to { transform: translate(40px, 30px) scale(1.08); } +} + +/* ============ Header ============ */ +.site-header { + width: 100%; + max-width: 620px; + margin-bottom: 1.25rem; + position: relative; + z-index: 1; +} + +.header-inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.brand-icon { + width: 46px; + height: 46px; + border-radius: 12px; + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + box-shadow: 0 8px 24px rgba(56, 189, 248, 0.35); + flex-shrink: 0; +} + +.brand-icon svg { + width: 26px; + height: 26px; +} + +h1 { + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.1; +} + +.subtitle { + color: var(--muted); + font-size: 0.88rem; + margin-top: 0.2rem; +} + +.header-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.lang-select select { + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.5rem; + font-size: 0.85rem; + cursor: pointer; +} + +.lang-select select:focus { + outline: none; + border-color: var(--accent); +} + +.theme-toggle { + width: 38px; + height: 38px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + cursor: pointer; + display: grid; + place-items: center; + transition: border-color 0.2s ease, transform 0.15s ease; +} + +.theme-toggle:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.theme-toggle svg { + width: 18px; + height: 18px; +} + +/* Show sun in dark mode, moon in light mode */ +[data-theme="dark"] .icon-sun, +:root:not([data-theme]) .icon-sun { display: block; } +[data-theme="dark"] .icon-moon, +:root:not([data-theme]) .icon-moon { display: none; } +[data-theme="light"] .icon-sun { display: none; } +[data-theme="light"] .icon-moon { display: block; } + +/* ============ Container / card ============ */ +.container { + background: var(--card); + border: 1px solid var(--border-soft); + border-radius: 18px; + padding: 1.75rem; + width: 100%; + max-width: 620px; + box-shadow: var(--shadow); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + position: relative; + z-index: 1; +} + +/* ============ Form ============ */ +.lookup-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + position: relative; +} + +label { + font-size: 0.82rem; + color: var(--muted); + font-weight: 500; +} + +.input-wrap { + position: relative; +} + +input[type="text"] { + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.65rem 0.8rem; + font-size: 1rem; + width: 100%; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input[type="text"]::placeholder { + color: var(--muted); + opacity: 0.7; +} + +input[type="text"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); +} + +/* ============ Primary button ============ */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + border: none; + border-radius: 10px; + padding: 0.75rem 1rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.25rem; + transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease; + box-shadow: 0 8px 22px rgba(56, 189, 248, 0.28); +} + +.btn-primary:hover { + transform: translateY(-1px); + filter: brightness(1.05); + box-shadow: 0 12px 28px rgba(56, 189, 248, 0.38); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-icon { + width: 18px; + height: 18px; +} + +/* ============ Suggestions dropdown ============ */ +.suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--card-solid); + border: 1px solid var(--border); + border-radius: 10px; + margin-top: 0.3rem; + z-index: 20; + max-height: 220px; + overflow-y: auto; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3); +} + +.suggestions.hidden { + display: none; +} + +.suggestion { + padding: 0.55rem 0.8rem; + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.suggestion:hover, +.suggestion.active { + background: var(--border-soft); +} + +.suggestion .code { + color: var(--accent); + font-weight: 700; + min-width: 2.2rem; +} + +/* ============ Result card ============ */ +.result { + margin-top: 1.5rem; + padding: 1.25rem; + border-radius: 14px; + border: 1px solid var(--border-soft); + background: var(--input-bg); + animation: fadeUp 0.35s ease; +} + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.result.hidden { + display: none; +} + +.result.error { + border-color: var(--error); + color: var(--error); + background: rgba(248, 113, 113, 0.08); + font-size: 0.95rem; + line-height: 1.5; +} + +/* Loading state */ +.result.loading { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--muted); + font-size: 0.95rem; +} + +.spinner { + width: 20px; + height: 20px; + border: 2.5px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.flight-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.1rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.flight-label { + font-weight: 700; + font-size: 1.08rem; + letter-spacing: -0.01em; +} + +.status-badge { + font-size: 0.72rem; + font-weight: 700; + padding: 0.3rem 0.7rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.04em; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.status-badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: pulse 1.8s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.45; transform: scale(0.8); } +} + +.status-badge.on-time { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.status-badge.delayed { + background: rgba(251, 191, 36, 0.15); + color: var(--warning); +} + +.status-badge.cancelled { + background: rgba(248, 113, 113, 0.15); + color: var(--error); +} + +.status-badge.airborne { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.status-badge.onGround { + background: rgba(148, 163, 184, 0.18); + color: var(--muted); +} + +/* Live / mock pills */ +.live-pill, +.mock-pill { + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + padding: 0.22rem 0.55rem; + border-radius: 999px; + text-transform: uppercase; +} + +.live-pill { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + box-shadow: 0 0 12px rgba(56, 189, 248, 0.4); +} + +.mock-pill { + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border: 1px solid var(--border); +} + +/* ============ Live stats ============ */ +.live-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.6rem; + margin: 0.4rem 0 0.2rem; +} + +.stat { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.6rem 0.7rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.stat-k { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + font-weight: 600; +} + +.stat-v { + font-size: 0.95rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* ============ Route ============ */ +.route { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.airport { + text-align: center; + min-width: 84px; +} + +.airport .code { + font-size: 1.6rem; + font-weight: 800; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + line-height: 1.1; +} + +.airport .city { + color: var(--muted); + font-size: 0.8rem; + margin-top: 0.2rem; +} + +.airport .time { + font-size: 1.05rem; + font-weight: 600; + margin-top: 0.45rem; +} + +.airport .tz { + color: var(--muted); + font-size: 0.72rem; + margin-top: 0.15rem; +} + +.route-mid { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 0 0.4rem; +} + +.route-line { + width: 100%; + height: 2px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-radius: 2px; + position: relative; +} + +.route-line::before, +.route-line::after { + content: ""; + position: absolute; + top: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + transform: translateY(-50%); +} + +.route-line::before { left: 0; } +.route-line::after { right: 0; background: var(--accent-2); } + +.route-duration { + color: var(--muted); + font-size: 0.78rem; + font-weight: 500; +} + +/* ============ Map ============ */ +.map-wrap { + margin-top: 1.25rem; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border-soft); + background: var(--map-bg); +} + +.map-wrap svg { + display: block; + width: 100%; + height: auto; +} + +.map-grid-line { + stroke: var(--map-grid); + stroke-width: 1; +} + +.map-route { + fill: none; + stroke: url(#routeGradient); + stroke-width: 2.5; + stroke-linecap: round; +} + +.map-point { + fill: var(--accent); +} + +.map-point-arr { + fill: var(--accent-2); +} + +.map-label { + fill: var(--map-text); + font-size: 11px; + font-weight: 600; + font-family: system-ui, sans-serif; +} + +.map-sublabel { + fill: var(--muted); + font-size: 9px; + font-family: system-ui, sans-serif; +} + +.map-live-dot { + fill: var(--accent); + stroke: #fff; + stroke-width: 2; +} + +.map-live-pulse { + fill: var(--accent); + opacity: 0.35; + transform-origin: center; + transform-box: fill-box; + animation: livePulse 2s ease-out infinite; +} + +@keyframes livePulse { + 0% { transform: scale(0.6); opacity: 0.5; } + 100% { transform: scale(2.4); opacity: 0; } +} + +.map-heading { + stroke: var(--accent-2); + stroke-width: 2.5; + stroke-linecap: round; +} + +/* ============ Share / meta row ============ */ +.result-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 1.1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-soft); + flex-wrap: wrap; + gap: 0.5rem; +} + +.share-link { + color: var(--accent); + font-size: 0.85rem; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 500; +} + +.share-link:hover { + text-decoration: underline; +} + +.share-link svg { + width: 14px; + height: 14px; +} + +.airline-name { + color: var(--muted); + font-size: 0.85rem; +} + +/* ============ History ============ */ +.history-section { + margin-top: 1.5rem; + position: relative; + z-index: 1; + width: 100%; + max-width: 620px; +} + +.history-section h2 { + font-size: 0.85rem; + color: var(--muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.6rem; +} + +.history-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; +} + +.history-item { + background: var(--card); + border: 1px solid var(--border-soft); + border-radius: 999px; + padding: 0.35rem 0.85rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + cursor: pointer; + transition: border-color 0.2s ease, transform 0.15s ease; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.history-item:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.history-item .h-code { + color: var(--accent); +} + +.history-empty { + color: var(--muted); + font-size: 0.85rem; +} + +/* ============ Footer ============ */ +.site-footer { + margin-top: 1.5rem; + color: var(--muted); + font-size: 0.78rem; + position: relative; + z-index: 1; + text-align: center; +} + +/* ============ Responsive ============ */ +@media (max-width: 480px) { + .container { + padding: 1.25rem; + } + + .airport .code { + font-size: 1.3rem; + } + + .airport .time { + font-size: 0.95rem; + } + + .header-inner { + flex-direction: column; + align-items: flex-start; + } + + .header-controls { + width: 100%; + justify-content: space-between; + } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..c50b700 --- /dev/null +++ b/sw.js @@ -0,0 +1,49 @@ +const CACHE_NAME = "flight-lookup-v1"; +const ASSETS = [ + "./", + "./index.html", + "./style.css", + "./data.js", + "./app.js", + "./manifest.json" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE_NAME) + .map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request) + .then((response) => { + if (response.ok && request.url.startsWith(self.location.origin)) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match("./index.html")); + }) + ); +});