init test commit
This commit is contained in:
306
trade-hub/frontend/src/app.js
Normal file
306
trade-hub/frontend/src/app.js
Normal file
@@ -0,0 +1,306 @@
|
||||
/* Star Citizen Inventory — vanilla JS frontend.
|
||||
* All API calls use RELATIVE paths (/api/v1/..., /healthz) served by the
|
||||
* nginx reverse proxy, so the app works behind any host/origin.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const state = {
|
||||
category: "",
|
||||
search: "",
|
||||
manufacturer: "",
|
||||
type: "",
|
||||
size: "",
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const els = {};
|
||||
|
||||
function cacheElements() {
|
||||
const ids = [
|
||||
"api-status", "stats-line", "category-tabs", "search-input",
|
||||
"manufacturer-select", "type-input", "size-input", "reset-filters",
|
||||
"error-banner", "loading", "empty-state", "results-grid",
|
||||
"pagination", "prev-page", "next-page", "page-info",
|
||||
"modal-backdrop", "modal-close", "modal-title", "modal-body",
|
||||
];
|
||||
for (const id of ids) els[id] = document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || `Request failed: ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/* ---------- header: status, stats, manufacturers ---------- */
|
||||
|
||||
async function initHeader() {
|
||||
try {
|
||||
const health = await fetchJson("/healthz");
|
||||
els["api-status"].textContent = "API ONLINE";
|
||||
els["api-status"].className = "status-pill online";
|
||||
const c = health.counts || {};
|
||||
els["stats-line"].textContent =
|
||||
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
|
||||
`${c["fps-items"] ?? 0} FPS items`;
|
||||
} catch {
|
||||
els["api-status"].textContent = "API OFFLINE";
|
||||
els["api-status"].className = "status-pill offline";
|
||||
showError("Cannot reach the API. Is the backend container running?");
|
||||
}
|
||||
|
||||
try {
|
||||
const manufacturers = await fetchJson("/api/v1/manufacturers");
|
||||
for (const m of manufacturers) {
|
||||
const option = document.createElement("option");
|
||||
option.value = m.code;
|
||||
option.textContent = m.name;
|
||||
els["manufacturer-select"].appendChild(option);
|
||||
}
|
||||
} catch {
|
||||
/* header error already shown; dropdown simply stays empty */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- item list ---------- */
|
||||
|
||||
function buildQuery() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.category) params.set("category", state.category);
|
||||
if (state.search) params.set("search", state.search);
|
||||
if (state.manufacturer) params.set("manufacturer", state.manufacturer);
|
||||
if (state.type) params.set("type", state.type);
|
||||
if (state.size !== "") params.set("size", state.size);
|
||||
params.set("page", String(state.page));
|
||||
params.set("page_size", String(PAGE_SIZE));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function categoryBadge(category) {
|
||||
const labels = { ships: "Ship", "ship-items": "Ship Item", "fps-items": "FPS Item" };
|
||||
return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
const grid = els["results-grid"];
|
||||
grid.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "item-card";
|
||||
card.dataset.class_name = item.class_name;
|
||||
const manufacturer = item.manufacturer?.name || item.manufacturer?.code || "—";
|
||||
const size = item.size ? `<span class="badge size">S${escapeHtml(item.size)}</span>` : "";
|
||||
const grade = item.grade ? `<span class="badge size">G${escapeHtml(item.grade)}</span>` : "";
|
||||
card.innerHTML = `
|
||||
<div class="item-name">${escapeHtml(item.name)}</div>
|
||||
<div class="item-manufacturer">${escapeHtml(manufacturer)}</div>
|
||||
<div class="item-classification">${escapeHtml(item.classification || item.type || "")}</div>
|
||||
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}</div>
|
||||
`;
|
||||
card.addEventListener("click", () => openModal(item.class_name));
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination(body) {
|
||||
state.totalPages = body.total_pages;
|
||||
els["page-info"].textContent = `Page ${body.page} of ${body.total_pages} — ${body.total} items`;
|
||||
els["prev-page"].disabled = body.page <= 1;
|
||||
els["next-page"].disabled = body.page >= body.total_pages;
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
els["loading"].classList.remove("hidden");
|
||||
els["empty-state"].classList.add("hidden");
|
||||
els["results-grid"].innerHTML = "";
|
||||
els["error-banner"].classList.add("hidden");
|
||||
try {
|
||||
const body = await fetchJson(`/api/v1/items?${buildQuery()}`);
|
||||
renderItems(body.items);
|
||||
renderPagination(body);
|
||||
els["empty-state"].classList.toggle("hidden", body.total !== 0);
|
||||
} catch (err) {
|
||||
showError(`Failed to load items: ${err.message}`);
|
||||
} finally {
|
||||
els["loading"].classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els["error-banner"].textContent = message;
|
||||
els["error-banner"].classList.remove("hidden");
|
||||
}
|
||||
|
||||
/* ---------- detail modal ---------- */
|
||||
|
||||
const SHIP_DETAIL_KEYS = [
|
||||
["career", "Career"], ["role", "Role"], ["length", "Length (m)"],
|
||||
["width", "Width (m)"], ["height", "Height (m)"], ["crew", "Crew"],
|
||||
["mass", "Mass (kg)"], ["is_spaceship", "Spaceship"],
|
||||
["is_vehicle", "Vehicle"], ["is_gravlev", "Gravlev"],
|
||||
];
|
||||
const ITEM_DETAIL_KEYS = [
|
||||
["mass", "Mass (kg)"], ["width", "Width (m)"], ["height", "Height (m)"],
|
||||
["length", "Length (m)"],
|
||||
];
|
||||
|
||||
function formatValue(value) {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
}
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function renderPorts(ports) {
|
||||
if (!Array.isArray(ports) || ports.length === 0) return "";
|
||||
const rows = ports
|
||||
.map(
|
||||
(p) => `<tr><th>${escapeHtml(p.DisplayName || p.PortName || "Port")}</th>` +
|
||||
`<td>size ${escapeHtml(p.Size ?? "—")}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
return `<h3>Ports</h3><table class="spec-table">${rows}</table>`;
|
||||
}
|
||||
|
||||
async function openModal(class_name) {
|
||||
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
|
||||
els["modal-backdrop"].classList.remove("hidden");
|
||||
try {
|
||||
const item = await fetchJson(`/api/v1/items/${encodeURIComponent(class_name)}`);
|
||||
els["modal-title"].textContent = item.name;
|
||||
const manufacturer = item.manufacturer?.name || "—";
|
||||
const tags = (item.tags || [])
|
||||
.map((t) => `<span class="badge size">${escapeHtml(t)}</span>`)
|
||||
.join("");
|
||||
const keys = item.category === "ships" ? SHIP_DETAIL_KEYS : ITEM_DETAIL_KEYS;
|
||||
const rows = keys
|
||||
.map(([key, label]) => `<tr><th>${label}</th><td>${formatValue(item.details?.[key])}</td></tr>`)
|
||||
.join("");
|
||||
els["modal-body"].innerHTML = `
|
||||
<p class="modal-sub">${escapeHtml(manufacturer)} · ${escapeHtml(item.class_name)}</p>
|
||||
${item.description ? `<p class="modal-desc">${escapeHtml(item.description)}</p>` : ""}
|
||||
${tags ? `<div class="tag-row">${tags}</div>` : ""}
|
||||
<h3>Specifications</h3>
|
||||
<table class="spec-table">${rows}</table>
|
||||
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
|
||||
`;
|
||||
} catch (err) {
|
||||
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
els["modal-backdrop"].classList.add("hidden");
|
||||
}
|
||||
|
||||
/* ---------- events ---------- */
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function resetPage() {
|
||||
state.page = 1;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
els["category-tabs"].addEventListener("click", (event) => {
|
||||
const tab = event.target.closest(".tab");
|
||||
if (!tab) return;
|
||||
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
state.category = tab.dataset.category;
|
||||
resetPage();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
state.search = els["search-input"].value.trim();
|
||||
resetPage();
|
||||
loadItems();
|
||||
}, 300);
|
||||
els["search-input"].addEventListener("input", debouncedSearch);
|
||||
|
||||
els["manufacturer-select"].addEventListener("change", () => {
|
||||
state.manufacturer = els["manufacturer-select"].value;
|
||||
resetPage();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
els["type-input"].addEventListener("change", () => {
|
||||
state.type = els["type-input"].value.trim();
|
||||
resetPage();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
els["size-input"].addEventListener("change", () => {
|
||||
state.size = els["size-input"].value;
|
||||
resetPage();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
els["reset-filters"].addEventListener("click", () => {
|
||||
state.search = "";
|
||||
state.manufacturer = "";
|
||||
state.type = "";
|
||||
state.size = "";
|
||||
els["search-input"].value = "";
|
||||
els["manufacturer-select"].value = "";
|
||||
els["type-input"].value = "";
|
||||
els["size-input"].value = "";
|
||||
resetPage();
|
||||
loadItems();
|
||||
});
|
||||
|
||||
els["prev-page"].addEventListener("click", () => {
|
||||
if (state.page > 1) {
|
||||
state.page -= 1;
|
||||
loadItems();
|
||||
}
|
||||
});
|
||||
els["next-page"].addEventListener("click", () => {
|
||||
if (state.page < state.totalPages) {
|
||||
state.page += 1;
|
||||
loadItems();
|
||||
}
|
||||
});
|
||||
|
||||
els["modal-close"].addEventListener("click", closeModal);
|
||||
els["modal-backdrop"].addEventListener("click", (event) => {
|
||||
if (event.target === els["modal-backdrop"]) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
initHeader();
|
||||
loadItems();
|
||||
});
|
||||
73
trade-hub/frontend/src/index.html
Normal file
73
trade-hub/frontend/src/index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Star Citizen Inventory</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
|
||||
<p class="subtitle">Item catalog from scunpacked game data</p>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div id="api-status" class="status-pill checking">CHECKING API…</div>
|
||||
<div id="stats-line" class="stats-line">Loading counts…</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="category-tabs">
|
||||
<button class="tab active" data-category="">All Items</button>
|
||||
<button class="tab" data-category="ships">Ships</button>
|
||||
<button class="tab" data-category="ship-items">Ship Equipment</button>
|
||||
<button class="tab" data-category="fps-items">FPS Items</button>
|
||||
</nav>
|
||||
|
||||
<section class="controls">
|
||||
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
|
||||
<select id="manufacturer-select">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Data source:
|
||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||
— unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<div id="modal-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<button class="modal-close" id="modal-close" aria-label="Close">×</button>
|
||||
<h2 id="modal-title">Item</h2>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
276
trade-hub/frontend/src/style.css
Normal file
276
trade-hub/frontend/src/style.css
Normal file
@@ -0,0 +1,276 @@
|
||||
/* Star Citizen Inventory — dark sci-fi theme */
|
||||
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg-panel: #11151f;
|
||||
--bg-card: #151a26;
|
||||
--border: #232b3d;
|
||||
--text: #d7dce8;
|
||||
--text-dim: #8b93a7;
|
||||
--accent: #38bdf8;
|
||||
--accent-warm: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--cat-ships: #38bdf8;
|
||||
--cat-ship-items: #a78bfa;
|
||||
--cat-fps-items: #f59e0b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background: linear-gradient(180deg, #0d1220 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand h1 span { color: var(--accent); }
|
||||
.subtitle { margin: 0.25rem 0 0; color: var(--text-dim); font-size: 0.85rem; }
|
||||
|
||||
.header-status { text-align: right; }
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.status-pill.online { color: var(--success); border-color: var(--success); background: rgba(34,197,94,0.08); }
|
||||
.status-pill.offline { color: var(--danger); border-color: var(--danger); background: rgba(239,68,68,0.08); }
|
||||
.status-pill.checking { color: var(--text-dim); border-color: var(--border); }
|
||||
.stats-line { margin-top: 0.4rem; font-size: 0.8rem; color: var(--text-dim); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.6rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.controls input, .controls select {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
#search-input { flex: 2 1 240px; }
|
||||
#manufacturer-select { flex: 1 1 180px; }
|
||||
#type-input { flex: 1 1 160px; }
|
||||
#size-input { flex: 0 1 90px; }
|
||||
.controls input:focus, .controls select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
|
||||
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: #fca5a5;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading, .empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 3rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.empty-state .hint { font-size: 0.85rem; opacity: 0.7; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Results grid */
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.item-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.item-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.item-card .item-name { font-weight: 700; font-size: 0.98rem; line-height: 1.3; }
|
||||
.item-card .item-manufacturer { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.item-card .item-meta {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
|
||||
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
|
||||
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
|
||||
.badge.size { color: var(--text-dim); border-color: var(--border); }
|
||||
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
#page-info { color: var(--text-dim); font-size: 0.9rem; }
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 2rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.site-footer a { color: var(--accent); text-decoration: none; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5,8,14,0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.9rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal h2 { margin: 0 2rem 0.25rem 0; font-size: 1.25rem; }
|
||||
.modal .modal-sub { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.modal .modal-desc { font-size: 0.92rem; line-height: 1.55; margin-bottom: 1rem; }
|
||||
.modal .tag-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.spec-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
.spec-table th, .spec-table td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
|
||||
.spec-table td { font-family: ui-monospace, monospace; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { flex-direction: column; align-items: flex-start; }
|
||||
.header-status { text-align: left; }
|
||||
.controls input, .controls select { flex: 1 1 100%; }
|
||||
}
|
||||
Reference in New Issue
Block a user