Flight lookup: OpenSky live data, i18n, PWA, visual redesign

This commit is contained in:
chrisf committed 2026-09-30 20:06:15 +00:00
commit fde38516b4
10 files changed
+2022

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
credentials.json
.gitea-token
node_modules/
server.log
*.log
+34
View File
@@ -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.
+473
View File
@@ -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 = `<span class="h-code">${entry.code}</span>${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 = `<span class="code">${airline.code}</span> ${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 = `<div class="spinner"></div><span>${t("searching")}</span>`;
}
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 = `
<div class="flight-header">
<span class="flight-label">${flight.airlineCode} ${flight.flightNumber}</span>
<span class="status-badge ${flight.status}">${statusText}</span>
${isLive ? `<span class="live-pill">${t("live")}</span>` : `<span class="mock-pill">${t("mock")}</span>`}
</div>
${body}
<div class="map-wrap">${renderMap(flight)}</div>
<div class="result-meta">
<span class="airline-name">${flight.airline}</span>
<a class="share-link" href="${shareUrl}" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
${t("copyLink")}
</a>
</div>
`;
}
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 `
<div class="route">
<div class="airport">
<div class="code">${flight.origin.code}</div>
<div class="city">${flight.origin.city}</div>
<div class="time">${depTime}</div>
<div class="tz">${flight.origin.tz}</div>
</div>
<div class="route-mid">
<div class="route-line"></div>
<div class="route-duration">${duration}</div>
</div>
<div class="airport">
<div class="code">${flight.destination.code}</div>
<div class="city">${flight.destination.city}</div>
<div class="time">${arrTime}</div>
<div class="tz">${flight.destination.tz}</div>
</div>
</div>
`;
}
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 `
<div class="live-stats">
${rows
.map(
([k, v]) => `
<div class="stat">
<span class="stat-k">${k}</span>
<span class="stat-v">${v}</span>
</div>`
)
.join("")}
</div>
`;
}
// ============ 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 += `<line class="map-grid-line" x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"/>`;
}
for (let lat = -90; lat <= 90; lat += 30) {
const p1 = project(lat, -180);
const p2 = project(lat, 180);
grid += `<line class="map-grid-line" x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"/>`;
}
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 = `
<circle class="map-live-pulse" cx="${pos.x}" cy="${pos.y}" r="10"/>
<circle class="map-live-dot" cx="${pos.x}" cy="${pos.y}" r="6"/>
<line class="map-heading" x1="${pos.x}" y1="${pos.y}" x2="${hx}" y2="${hy}"/>
<text class="map-label" x="${pos.x}" y="${pos.y - 16}" text-anchor="middle">${flight.airlineCode}${flight.flightNumber}</text>
<text class="map-sublabel" x="${pos.x}" y="${pos.y + 24}" text-anchor="middle">${p.altitudeFt != null ? p.altitudeFt.toLocaleString() + " ft" : ""}</text>
`;
} 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 = `
<path class="map-route" d="M ${from.x} ${from.y} Q ${ctrlX} ${ctrlY} ${to.x} ${to.y}"/>
<circle class="map-point" cx="${from.x}" cy="${from.y}" r="5"/>
<circle class="map-point-arr" cx="${to.x}" cy="${to.y}" r="5"/>
<text class="map-label" x="${from.x}" y="${from.y - 12}" text-anchor="middle">${flight.origin.code}</text>
<text class="map-sublabel" x="${from.x}" y="${from.y + 20}" text-anchor="middle">${flight.origin.city}</text>
<text class="map-label" x="${to.x}" y="${to.y - 12}" text-anchor="middle">${flight.destination.code}</text>
<text class="map-sublabel" x="${to.x}" y="${to.y + 20}" text-anchor="middle">${flight.destination.city}</text>
`;
}
return `
<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="${aria}">
<defs>
<linearGradient id="routeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="var(--accent)"/>
<stop offset="100%" stop-color="var(--accent-2)"/>
</linearGradient>
</defs>
${grid}
${content}
</svg>
`;
}
// ============ 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);
+318
View File
@@ -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] };
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Look up flight routes, status, and times by airline and flight number." />
<meta name="theme-color" content="#0f172a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" />
<link rel="apple-touch-icon" href="icon-192.png" />
<title>Flight Lookup</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="bg-glow bg-glow-1" aria-hidden="true"></div>
<div class="bg-glow bg-glow-2" aria-hidden="true"></div>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<div class="brand-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>
</svg>
</div>
<div>
<h1>Flight Lookup</h1>
<p class="subtitle">Enter an airline and flight number to see its route.</p>
</div>
</div>
<div class="header-controls">
<label class="lang-select">
<span class="sr-only" for="lang">Language</span>
<select id="lang" aria-label="Language">
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="de">DE</option>
<option value="ja">JA</option>
</select>
</label>
<button id="theme-toggle" class="theme-toggle" type="button" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</div>
</div>
</header>
<main class="container">
<form id="lookup-form" class="lookup-form" autocomplete="off">
<div class="field field-airline">
<label for="airline">Airline</label>
<div class="input-wrap">
<input
id="airline"
type="text"
placeholder="e.g. American, BA, Lufthansa…"
autocomplete="off"
spellcheck="false"
/>
<div id="airline-suggestions" class="suggestions hidden"></div>
</div>
</div>
<div class="field field-flight">
<label for="flight-number">Flight number</label>
<input
id="flight-number"
type="text"
inputmode="numeric"
placeholder="e.g. 100"
autocomplete="off"
maxlength="5"
/>
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Look up flight</span>
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
</form>
<div id="result" class="result hidden" aria-live="polite"></div>
<section id="history-section" class="history hidden">
<h2>Recent lookups</h2>
<ul id="history-list" class="history-list"></ul>
</section>
</main>
<footer class="site-footer">
<span>Mock data · no real flight info</span>
</footer>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>
+18
View File
@@ -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"
}
]
}
+207
View File
@@ -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"}`);
});
+814
View File
@@ -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;
}
}
+49
View File
@@ -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"));
})
);
});