Flight lookup: OpenSky live data, i18n, PWA, visual redesign
This commit is contained in:
commit
fde38516b4
10 files changed
+2022
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
credentials.json
|
||||||
|
.gitea-token
|
||||||
|
node_modules/
|
||||||
|
server.log
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
@@ -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] };
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 697 B |
+104
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"}`);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user