From 26fa6ea6b94b6142335108ad4667fa779d252cd7 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 25 Jun 2026 21:39:21 +0000 Subject: [PATCH] initial commit --- api/_bootstrap.php | 54 ++++ api/data_get.php | 24 ++ api/data_put.php | 45 ++++ api/login.php | 20 ++ api/logout.php | 17 ++ app.js | 641 +++++++++++++++++++++++++++++++++++++++++++++ data/.htacess | 2 + data/app.json | 12 + index.php | 75 ++++++ styles.css | 220 ++++++++++++++++ 10 files changed, 1110 insertions(+) create mode 100644 api/_bootstrap.php create mode 100644 api/data_get.php create mode 100644 api/data_put.php create mode 100644 api/login.php create mode 100644 api/logout.php create mode 100644 app.js create mode 100755 data/.htacess create mode 100644 data/app.json create mode 100644 index.php create mode 100644 styles.css diff --git a/api/_bootstrap.php b/api/_bootstrap.php new file mode 100644 index 0000000..825a331 --- /dev/null +++ b/api/_bootstrap.php @@ -0,0 +1,54 @@ + false, 'error' => 'not_authenticated'], 401); + } +} + +function read_json_file(string $path): array { + if (!file_exists($path)) return []; + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') return []; + $data = json_decode($raw, true); + return is_array($data) ? $data : []; +} + +function write_json_file_atomic(string $path, string $json): bool { + // Ensure directory exists + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0750, true)) return false; + } + + $fp = fopen($path, 'c+'); + if ($fp === false) return false; + + $ok = false; + if (flock($fp, LOCK_EX)) { + ftruncate($fp, 0); + rewind($fp); + $bytes = fwrite($fp, $json); + fflush($fp); + flock($fp, LOCK_UN); + $ok = ($bytes !== false); + } + fclose($fp); + return $ok; +} \ No newline at end of file diff --git a/api/data_get.php b/api/data_get.php new file mode 100644 index 0000000..878b1d1 --- /dev/null +++ b/api/data_get.php @@ -0,0 +1,24 @@ + ['schema' => 1, 'updatedAt' => gmdate('c')], + 'trips' => [], + 'shopping' => [], + 'preTripTodos' => [], + 'preTripChecklist' => [], + 'upgrades' => [], + 'fixes' => [], + ]; +} + +json_response(['ok' => true, 'data' => $data]); \ No newline at end of file diff --git a/api/data_put.php b/api/data_put.php new file mode 100644 index 0000000..d2ea7f5 --- /dev/null +++ b/api/data_put.php @@ -0,0 +1,45 @@ + false, 'error' => 'no_body'], 400); +} + +if (strlen($raw) > $max) { + json_response(['ok' => false, 'error' => 'payload_too_large'], 413); +} + +$data = json_decode($raw, true); +if (!is_array($data)) { + json_response(['ok' => false, 'error' => 'invalid_json'], 400); +} + +// Minimal sanity enforcement +$data['meta'] = $data['meta'] ?? []; +$data['meta']['schema'] = 1; +$data['meta']['updatedAt'] = gmdate('c'); + +// Only allow the keys we expect (keeps junk out) +$allowed = ['meta','trips','shopping','preTripTodos','preTripChecklist','upgrades','fixes']; +$clean = []; +foreach ($allowed as $k) { + $clean[$k] = $data[$k] ?? ($k === 'meta' ? $data['meta'] : []); +} + +$json = json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); +if ($json === false) { + json_response(['ok' => false, 'error' => 'encode_failed'], 500); +} + +$ok = write_json_file_atomic($config['DATA_FILE'], $json . "\n"); +if (!$ok) { + json_response(['ok' => false, 'error' => 'write_failed'], 500); +} + +json_response(['ok' => true]); \ No newline at end of file diff --git a/api/login.php b/api/login.php new file mode 100644 index 0000000..9102cf8 --- /dev/null +++ b/api/login.php @@ -0,0 +1,20 @@ + { + const $ = (sel) => document.querySelector(sel); + + const TABS = [ + { id: "trips", label: "Trips" }, + { id: "shopping", label: "Shopping" }, + { id: "todo", label: "Pre-trip Do" }, + { id: "check", label: "Pre-trip Check" }, + { id: "upgrades", label: "Upgrades" }, + { id: "fixes", label: "Fixes" }, + ]; + + const state = { + activeTab: "trips", + data: null, + dirty: false, + saveTimer: null, + }; + + const uid = () => Math.random().toString(16).slice(2) + Date.now().toString(16); + + const mapsLink = (address) => + `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address || "")}`; + + function setStatus(msg) { + const el = $("#status"); + if (el) el.textContent = msg; + } + + function escapeHtml(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function safeUrl(u) { + const s = String(u ?? "").trim(); + if (!s) return ""; + if (/^https?:\/\//i.test(s)) return s; + return ""; + } + + function panel(title, subtitle, innerHTML) { + return ` +
+

${escapeHtml(title)}

+
${escapeHtml(subtitle || "")}
+
+ ${innerHTML} +
+ `; + } + + async function apiGet() { + const res = await fetch("/api/data_get.php", { credentials: "include" }); + if (!res.ok) throw new Error(`GET failed: ${res.status}`); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || "GET error"); + return j.data; + } + + async function apiPut(payload) { + const res = await fetch("/api/data_put.php", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`PUT failed: ${res.status}`); + const j = await res.json(); + if (!j.ok) throw new Error(j.error || "PUT error"); + return true; + } + + function normalize(d) { + return { + meta: { schema: 1, updatedAt: new Date().toISOString() }, + trips: Array.isArray(d?.trips) ? d.trips : [], + shopping: Array.isArray(d?.shopping) ? d.shopping : [], + preTripTodos: Array.isArray(d?.preTripTodos) ? d.preTripTodos : [], + preTripChecklist: Array.isArray(d?.preTripChecklist) ? d.preTripChecklist : [], + upgrades: Array.isArray(d?.upgrades) ? d.upgrades : [], + fixes: Array.isArray(d?.fixes) ? d.fixes : [], + }; + } + + function markDirty() { + state.dirty = true; + setStatus("Unsaved changes..."); + if (state.saveTimer) clearTimeout(state.saveTimer); + state.saveTimer = setTimeout(saveNow, 650); + } + + async function saveNow() { + if (!state.dirty) return; + state.dirty = false; + + try { + const payload = (typeof structuredClone === "function") + ? structuredClone(state.data) + : JSON.parse(JSON.stringify(state.data)); + + payload.meta = payload.meta || {}; + payload.meta.schema = 1; + payload.meta.updatedAt = new Date().toISOString(); + + setStatus("Saving..."); + await apiPut(payload); + setStatus("Saved."); + } catch (e) { + state.dirty = true; + setStatus(`Save failed: ${e.message}`); + } + } + + function renderTabs() { + const tabs = $("#tabs"); + if (!tabs) return; + tabs.innerHTML = ""; + + for (const t of TABS) { + const b = document.createElement("button"); + b.className = "tab" + (state.activeTab === t.id ? " active" : ""); + b.textContent = t.label; + b.onclick = () => { + state.activeTab = t.id; + render(); + }; + tabs.appendChild(b); + } + } + + function moneyLabel(v) { + if (v === "" || v == null) return ""; + const n = Number(v); + if (Number.isNaN(n)) return String(v); + return n.toLocaleString(undefined, { style: "currency", currency: "USD" }); + } + + // ---------- Trips ---------- + function renderTrips() { + const trips = state.data.trips || []; + const sorted = [...trips].sort((a, b) => (b.startDate || "").localeCompare(a.startDate || "")); + + const form = ` +
+

Upcoming Trips

+
Name, contact, dates, cost, paid status, venue link, and a one-click Maps link.
+
+ +
+ + + + + + + + +
+ +
+ +
+
+ `; + + const list = sorted.length + ? `
${ + sorted + .map((t) => { + const badge = t.paid + ? `Paid` + : `Unpaid`; + + const pills = [ + t.cost !== "" ? `${escapeHtml(moneyLabel(t.cost))}` : "", + t.phone ? `${escapeHtml(t.phone)}` : "", + (t.startDate || t.endDate) + ? `${escapeHtml((t.startDate || "") + (t.endDate ? " \u2192 " + t.endDate : ""))}` + : "", + ].filter(Boolean).join(""); + + const addr = t.address ? `
${escapeHtml(t.address)}
` : ""; + + const mapsBtn = t.address + ? `Maps` + : ``; + + const venue = safeUrl(t.venueWebsite); + const venueBtn = venue + ? `Venue` + : ``; + + return ` +
+
+
+
+
${escapeHtml(t.name)}
+ ${badge} +
+ ${pills ? `
${pills}
` : ""} + ${addr} +
+
+
+ ${mapsBtn} + ${venueBtn} + + +
+
+ `; + }) + .join("") + }
` + : `
No trips yet.
`; + + return form + list; + } + + function bindTrips() { + $("#trip_add")?.addEventListener("click", () => { + const name = $("#trip_name").value.trim(); + if (!name) return; + + const trip = { + id: uid(), + createdAt: new Date().toISOString(), + name, + address: $("#trip_address").value.trim(), + phone: $("#trip_phone").value.trim(), + startDate: $("#trip_start").value, + endDate: $("#trip_end").value, + cost: $("#trip_cost").value.trim(), + paid: $("#trip_paid").checked, + venueWebsite: $("#trip_website").value.trim(), + }; + + state.data.trips = [trip, ...(state.data.trips || [])]; + markDirty(); + render(); + }); + } + + // ---------- Generic Checklist ---------- + function renderChecklist(opts) { + const items = opts.items || []; + + const addForm = ` +
+ + ${opts.allowStore ? `` : `
`} + + ${opts.extraForm ? opts.extraForm() : ""} +
+
+ +
+ `; + + const list = items.length + ? `
${ + items + .map((x) => { + // NOTE: no "Notes" pill; notes render as plain text below. + const pills = [ + x.store ? `Store: ${escapeHtml(x.store)}` : "", + x.partsNeeded ? `Parts: ${escapeHtml(x.partsNeeded)}` : "", + typeof x.purchased === "boolean" + ? `${x.purchased ? "Parts purchased" : "Parts NOT purchased"}` + : "", + ].filter(Boolean).join(""); + + return ` +
+
+ +
+
${escapeHtml(x.label || "")}
+ ${pills ? `
${pills}
` : ""} + ${x.notes ? `
${escapeHtml(x.notes)}
` : ""} +
+
+
+ + +
+
+ `; + }) + .join("") + }
` + : `
No items yet.
`; + + return panel(opts.title, opts.subtitle, addForm + "
" + list); + } + + function bindChecklist(opts) { + $(`#${opts.id}_add`)?.addEventListener("click", () => { + const label = $(`#${opts.id}_label`).value.trim(); + if (!label) return; + + const item = { id: uid(), label, done: false }; + if (opts.allowStore) item.store = $(`#${opts.id}_store`).value.trim(); + const notes = $(`#${opts.id}_notes`).value.trim(); + if (notes) item.notes = notes; + + if (opts.extraRead) Object.assign(item, opts.extraRead()); + + const arr = state.data[opts.key] || []; + state.data[opts.key] = [item, ...arr]; + + markDirty(); + render(); + }); + } + + function renderFixes() { + return renderChecklist({ + id: "fixes", + key: "fixes", + title: "Things to Fix", + subtitle: "Track issues, parts required, and whether parts are already purchased.", + placeholder: "E.g., Replace anode rod", + items: state.data.fixes || [], + extraForm: () => ` + + + `, + extraRead: () => ({ + partsNeeded: $("#fixes_parts").value.trim(), + purchased: $("#fixes_purchased").checked, + }), + }); + } + + function renderShopping() { + return renderChecklist({ + id: "shopping", + key: "shopping", + title: "Shopping List", + subtitle: "Check items off while you're grabbing them off the shelf or buying.", + placeholder: "E.g., 30A to 15A adapter", + allowStore: true, + items: state.data.shopping || [], + }); + } + + function renderTodo() { + return renderChecklist({ + id: "todo", + key: "preTripTodos", + title: "Pre-Trip Do List", + subtitle: "Tasks that can change depending on the trip.", + placeholder: "E.g., Fill fresh water tank", + items: state.data.preTripTodos || [], + }); + } + + function renderCheck() { + return renderChecklist({ + id: "check", + key: "preTripChecklist", + title: "Pre-Trip Checklist", + subtitle: "Repeatable checkboxes for go-time.", + placeholder: "E.g., Hitch locked & pin installed", + items: state.data.preTripChecklist || [], + }); + } + + function renderUpgrades() { + return renderChecklist({ + id: "upgrades", + key: "upgrades", + title: "Upgrades", + subtitle: "Wishlist and planned improvements for the trailer.", + placeholder: "E.g., Add A/C soft start", + items: state.data.upgrades || [], + }); + } + + function bindDelegates() { + const content = $("#content"); + if (!content) return; + + content.addEventListener("click", (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn) return; + + const action = btn.dataset.action; + const id = btn.dataset.id; + + // Trips + if (action === "trip_remove") { + state.data.trips = (state.data.trips || []).filter((t) => t.id !== id); + markDirty(); + render(); + return; + } + if (action === "trip_toggle_paid") { + state.data.trips = (state.data.trips || []).map((t) => (t.id === id ? { ...t, paid: !t.paid } : t)); + markDirty(); + render(); + return; + } + + // Removes for lists + const removeMap = { + shopping_remove: "shopping", + todo_remove: "preTripTodos", + check_remove: "preTripChecklist", + upgrades_remove: "upgrades", + fixes_remove: "fixes", + }; + + if (removeMap[action]) { + const key = removeMap[action]; + state.data[key] = (state.data[key] || []).filter((x) => x.id !== id); + markDirty(); + render(); + return; + } + + // Edits for lists (prompt-based) + const editMap = { + shopping_edit: "shopping", + todo_edit: "preTripTodos", + check_edit: "preTripChecklist", + upgrades_edit: "upgrades", + fixes_edit: "fixes", + }; + + if (editMap[action]) { + const key = editMap[action]; + const list = state.data[key] || []; + const item = list.find((z) => z.id === id); + if (!item) return; + + const newLabel = prompt("Edit item", item.label ?? ""); + if (newLabel === null) return; + + const newNotes = prompt("Edit notes (optional)", item.notes ?? ""); + if (newNotes === null) return; + + let newStore = item.store ?? ""; + if (key === "shopping") { + const v = prompt("Edit store (optional)", item.store ?? ""); + if (v === null) return; + newStore = v; + } + + let newParts = item.partsNeeded ?? ""; + let newPurchased = !!item.purchased; + if (key === "fixes") { + const p = prompt("Edit parts needed (optional)", item.partsNeeded ?? ""); + if (p === null) return; + newParts = p; + + const bought = prompt("Parts purchased? (yes/no)", item.purchased ? "yes" : "no"); + if (bought === null) return; + newPurchased = /^y(es)?$/i.test((bought || "").trim()); + } + + state.data[key] = list.map((z) => { + if (z.id !== id) return z; + const updated = { ...z, label: String(newLabel).trim() }; + + const nn = String(newNotes ?? "").trim(); + if (nn) updated.notes = nn; + else delete updated.notes; + + if (key === "shopping") { + const st = String(newStore ?? "").trim(); + if (st) updated.store = st; + else delete updated.store; + } + + if (key === "fixes") { + const pn = String(newParts ?? "").trim(); + if (pn) updated.partsNeeded = pn; + else delete updated.partsNeeded; + updated.purchased = !!newPurchased; + } + + return updated; + }); + + markDirty(); + render(); + return; + } + }); + + // Checkbox toggles (lists) + content.addEventListener("change", (e) => { + const el = e.target; + if (!el.matches("[data-action]")) return; + + const action = el.dataset.action; + const id = el.dataset.id; + + const toggleMap = { + shopping_toggle: "shopping", + todo_toggle: "preTripTodos", + check_toggle: "preTripChecklist", + upgrades_toggle: "upgrades", + fixes_toggle: "fixes", + }; + + if (toggleMap[action]) { + const key = toggleMap[action]; + state.data[key] = (state.data[key] || []).map((x) => (x.id === id ? { ...x, done: !!el.checked } : x)); + markDirty(); + } + }); + } + + function render() { + renderTabs(); + + const c = $("#content"); + if (!c) return; + + if (!state.data) { + c.innerHTML = panel("Loading...", "", `
Please wait.
`); + return; + } + + let htmlOut = ""; + switch (state.activeTab) { + case "trips": + htmlOut = renderTrips(); + break; + case "shopping": + htmlOut = renderShopping(); + break; + case "todo": + htmlOut = renderTodo(); + break; + case "check": + htmlOut = renderCheck(); + break; + case "upgrades": + htmlOut = renderUpgrades(); + break; + case "fixes": + htmlOut = renderFixes(); + break; + } + + c.innerHTML = htmlOut; + + if (state.activeTab === "trips") bindTrips(); + if (state.activeTab === "shopping") bindChecklist({ id: "shopping", key: "shopping", allowStore: true }); + if (state.activeTab === "todo") bindChecklist({ id: "todo", key: "preTripTodos" }); + if (state.activeTab === "check") bindChecklist({ id: "check", key: "preTripChecklist" }); + if (state.activeTab === "upgrades") bindChecklist({ id: "upgrades", key: "upgrades" }); + if (state.activeTab === "fixes") { + bindChecklist({ + id: "fixes", + key: "fixes", + extraRead: () => ({ + partsNeeded: $("#fixes_parts").value.trim(), + purchased: $("#fixes_purchased").checked, + }), + }); + } + } + + function bindTopbar() { + $("#btnLogout")?.addEventListener("click", () => { + window.location.href = "/api/logout.php"; + }); + + $("#btnExport")?.addEventListener("click", () => { + const blob = new Blob([JSON.stringify(state.data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `trailer-trekker-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }); + + $("#fileImport")?.addEventListener("change", async (e) => { + const f = e.target.files?.[0]; + if (!f) return; + try { + const text = await f.text(); + const parsed = JSON.parse(text); + state.data = normalize(parsed); + markDirty(); + render(); + setStatus("Imported. Saving..."); + await saveNow(); + } catch (err) { + setStatus("Import failed: " + err.message); + } finally { + e.target.value = ""; + } + }); + + window.addEventListener("beforeunload", (e) => { + if (state.dirty) { + e.preventDefault(); + e.returnValue = ""; + } + }); + } + + async function init() { + try { + setStatus("Loading from server..."); + const data = await apiGet(); + state.data = normalize(data); + setStatus("Ready."); + } catch (e) { + state.data = normalize({}); + setStatus(`Server load failed: ${e.message} (using empty data)`); + } + + render(); + bindTopbar(); + bindDelegates(); + } + + init(); +})(); \ No newline at end of file diff --git a/data/.htacess b/data/.htacess new file mode 100755 index 0000000..12b2598 --- /dev/null +++ b/data/.htacess @@ -0,0 +1,2 @@ +# Deny all access to the data directory +Require all denied \ No newline at end of file diff --git a/data/app.json b/data/app.json new file mode 100644 index 0000000..98c0b83 --- /dev/null +++ b/data/app.json @@ -0,0 +1,12 @@ +{ + "meta": { + "schema": 1, + "updatedAt": "2026-04-21T06:29:38+00:00" + }, + "trips": [], + "shopping": [], + "preTripTodos": [], + "preTripChecklist": [], + "upgrades": [], + "fixes": [] +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..62fa900 --- /dev/null +++ b/index.php @@ -0,0 +1,75 @@ + + + + + + + Trailer Trekker + + + + +
+
+

Trailer Trekker

+

Login

+ +
+ + + + + +
Invalid username or password.
+ + + +
+ +

+ Tip: This is a single-user site; credentials live in /var/www/TRAILER_TREKKER/private/config.php. +

+
+
+ +
+
+
Trailer Trekker
+
Trips • Lists • Checklists • Upgrades • Fixes
+
+ +
+ + + +
+
+ +
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e229d19 --- /dev/null +++ b/styles.css @@ -0,0 +1,220 @@ +:root{ + --bg:#0a0a0d; + --panel:#111117; + --panel2:#15151d; + --border:#2a2a36; + --text:#f2f2f6; + --muted:#a9a9b5; + --good:#22c55e; + --warn:#f59e0b; + --bad:#ef4444; + --btn:#1f1f2a; + --btn2:#2a2a38; + --accent:#60a5fa; + --shadow: 0 10px 25px rgba(0,0,0,.35); + --radius:16px; + --radius2:22px; + --gap:14px; + font-synthesis-weight:none; +} + +*{box-sizing:border-box} +html,body{height:100%} +body{ + margin:0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; + background: radial-gradient(1000px 600px at 20% 0%, rgba(96,165,250,.15), transparent 55%), + radial-gradient(900px 500px at 100% 10%, rgba(34,197,94,.10), transparent 55%), + var(--bg); + color:var(--text); +} + +a{color:var(--accent); text-decoration:none} +a:hover{text-decoration:underline} +code{background:#0f0f15; padding:2px 6px; border-radius:10px; border:1px solid var(--border)} + +.page{min-height:100%; display:grid; place-items:center; padding:24px;} +.card{ + background:rgba(17,17,23,.75); + border:1px solid var(--border); + border-radius:var(--radius2); + box-shadow:var(--shadow); + backdrop-filter: blur(10px); +} + +.auth{width:min(420px, 95vw); padding:22px;} +.auth h1{margin:0 0 6px 0; font-size:26px} +.muted{color:var(--muted)} +.small{font-size:12px} +.error{ + background: rgba(239,68,68,.10); + border:1px solid rgba(239,68,68,.25); + color:#fecaca; + padding:10px 12px; + border-radius:14px; + margin:10px 0 0 0; +} + +.form{display:flex; flex-direction:column; gap:12px; margin-top:14px} +label span{display:block; font-size:13px; color:var(--muted); margin-bottom:6px} +input, textarea, select{ + width:100%; + background:rgba(10,10,13,.6); + border:1px solid var(--border); + color:var(--text); + padding:11px 12px; + border-radius:14px; + outline:none; +} +textarea{min-height:90px; resize:vertical} +input:focus, textarea:focus{border-color:rgba(96,165,250,.6); box-shadow:0 0 0 3px rgba(96,165,250,.15)} + +.topbar{ + position:sticky; top:0; + z-index:10; + display:flex; align-items:center; justify-content:space-between; + gap:16px; + padding:14px 16px; + background:rgba(10,10,13,.75); + border-bottom:1px solid var(--border); + backdrop-filter: blur(10px); +} +.brand .title{font-size:18px; font-weight:700} +.brand .subtitle{font-size:12px; color:var(--muted); margin-top:2px} +.topbar-actions{display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end} + +.btn{ + background:var(--btn); + border:1px solid var(--border); + color:var(--text); + padding:10px 12px; + border-radius:14px; + cursor:pointer; + user-select:none; +} +.btn:hover{background:var(--btn2)} +.btn.primary{background:rgba(96,165,250,.18); border-color:rgba(96,165,250,.35)} +.btn.primary:hover{background:rgba(96,165,250,.25)} +.btn.danger{background:rgba(239,68,68,.12); border-color:rgba(239,68,68,.25)} +.btn.danger:hover{background:rgba(239,68,68,.18)} +.file-btn{display:inline-flex; align-items:center; gap:8px} + +.container{max-width:1050px; margin:0 auto; padding:16px} +.tabs{ + display:flex; flex-wrap:wrap; + gap:10px; + padding:10px; + border:1px solid var(--border); + border-radius:var(--radius2); + background:rgba(17,17,23,.55); +} +.tab{ + padding:10px 12px; + border-radius:14px; + border:1px solid transparent; + background:transparent; + color:var(--muted); + cursor:pointer; +} +.tab.active{ + color:var(--text); + background:rgba(96,165,250,.12); + border-color:rgba(96,165,250,.25); +} +.content{margin-top:14px; display:grid; gap:14px} +.panel{ + border:1px solid var(--border); + border-radius:var(--radius2); + background:rgba(17,17,23,.65); + padding:14px; +} +.panel h2{margin:0; font-size:18px} +.panel .sub{margin-top:6px; color:var(--muted); font-size:13px} + +.row{display:flex; gap:12px; flex-wrap:wrap} +.row > *{flex:1} +.grid2{display:grid; grid-template-columns: 1fr 1fr; gap:12px} +@media (max-width:720px){ .grid2{grid-template-columns:1fr} } + +.items{display:grid; gap:10px; margin-top:12px} +.item{ + display:flex; justify-content:space-between; align-items:flex-start; + gap:12px; + border:1px solid var(--border); + border-radius:16px; + background:rgba(10,10,13,.35); + padding:12px; +} +.item-left{display:flex; gap:10px; align-items:flex-start} +.item-title{font-weight:650} +.item-title.done{color:var(--muted); text-decoration:line-through} +.pills{display:flex; gap:8px; flex-wrap:wrap; margin-top:6px} +.pill{ + font-size:12px; + color:var(--text); + background:rgba(42,42,54,.55); + border:1px solid var(--border); + padding:3px 8px; + border-radius:999px; +} +.badge{font-size:12px; padding:4px 8px; border-radius:999px; border:1px solid var(--border)} +.badge.good{background:rgba(34,197,94,.12); border-color:rgba(34,197,94,.25); color:#bbf7d0} +.badge.warn{background:rgba(245,158,11,.12); border-color:rgba(245,158,11,.25); color:#fde68a} + +.footer{margin-top:10px; padding:6px 2px} +.actions{display:flex; gap:10px; flex-wrap:wrap} +hr{border:none; border-top:1px solid var(--border); margin:12px 0} + +/* --- Trailer Trekker: tighter, more cohesive list rows --- */ +.item{ + display:flex; + align-items:center; /* centers checkbox + text + actions */ + justify-content:space-between; + gap:14px; +} + +.item-left{ + display:flex; + align-items:center; /* checkbox aligns with title */ + gap:12px; + min-width:0; + flex:1; +} + +.item-left input[type="checkbox"]{ + width:18px; + height:18px; + margin:0; + flex:0 0 auto; +} + +.item-left > div{ + min-width:0; + flex:1; +} + +.item-title{ + font-size:15px; + line-height:1.2; +} + +.pills{ + margin-top:6px; +} + +.actions{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:10px; + flex:0 0 auto; +} + +.actions .btn{ + padding:8px 10px; /* less bulky */ + border-radius:12px; +} + +.btn.danger{ + white-space:nowrap; /* keeps “Remove” from wrapping */ +}