initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
ini_set('session.cookie_httponly', '1');
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$config = require '/var/www/TRAILER_TREKKER/private/config.php';
|
||||||
|
|
||||||
|
function json_response($data, int $code = 200): never {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_login(): void {
|
||||||
|
if (empty($_SESSION['tt_logged_in'])) {
|
||||||
|
json_response(['ok' => 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/_bootstrap.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$dataFile = $config['DATA_FILE'];
|
||||||
|
|
||||||
|
$data = read_json_file($dataFile);
|
||||||
|
|
||||||
|
// If file is empty/uninitialized, return defaults
|
||||||
|
if (empty($data)) {
|
||||||
|
$data = [
|
||||||
|
'meta' => ['schema' => 1, 'updatedAt' => gmdate('c')],
|
||||||
|
'trips' => [],
|
||||||
|
'shopping' => [],
|
||||||
|
'preTripTodos' => [],
|
||||||
|
'preTripChecklist' => [],
|
||||||
|
'upgrades' => [],
|
||||||
|
'fixes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['ok' => true, 'data' => $data]);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/_bootstrap.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$max = (int)$config['MAX_JSON_BYTES'];
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
json_response(['ok' => 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]);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
$username = $_POST['username'] ?? '';
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
$expectedUser = $config['USERNAME'];
|
||||||
|
$expectedHash = $config['PASSWORD_HASH'];
|
||||||
|
|
||||||
|
if ($username === $expectedUser && password_verify($password, $expectedHash)) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['tt_logged_in'] = true;
|
||||||
|
header('Location: /');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /?err=1');
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/_bootstrap.php';
|
||||||
|
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get("session.use_cookies")) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$params["path"], $params["domain"],
|
||||||
|
(bool)$params["secure"], (bool)$params["httponly"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
|
||||||
|
header('Location: /');
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
(() => {
|
||||||
|
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 `
|
||||||
|
<div class="panel">
|
||||||
|
<h2>${escapeHtml(title)}</h2>
|
||||||
|
<div class="sub">${escapeHtml(subtitle || "")}</div>
|
||||||
|
<hr />
|
||||||
|
${innerHTML}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Upcoming Trips</h2>
|
||||||
|
<div class="sub">Name, contact, dates, cost, paid status, venue link, and a one-click Maps link.</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<label><span>Name</span><input id="trip_name" placeholder="Campground / Park name"></label>
|
||||||
|
<label><span>Phone</span><input id="trip_phone" placeholder="(###) ###-####"></label>
|
||||||
|
<label><span>Address</span><input id="trip_address" placeholder="Street, City, State"></label>
|
||||||
|
<label><span>Venue website</span><input id="trip_website" placeholder="https://..."></label>
|
||||||
|
<label><span>Start date</span><input id="trip_start" type="date"></label>
|
||||||
|
<label><span>End date</span><input id="trip_end" type="date"></label>
|
||||||
|
<label><span>Cost</span><input id="trip_cost" placeholder="e.g. 245.00"></label>
|
||||||
|
<label style="display:flex;gap:10px;align-items:center;margin-top:26px">
|
||||||
|
<input id="trip_paid" type="checkbox" style="width:auto">
|
||||||
|
<span class="muted">Paid?</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="margin-top:12px">
|
||||||
|
<button class="btn primary" id="trip_add">Add Trip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const list = sorted.length
|
||||||
|
? `<div class="items">${
|
||||||
|
sorted
|
||||||
|
.map((t) => {
|
||||||
|
const badge = t.paid
|
||||||
|
? `<span class="badge good">Paid</span>`
|
||||||
|
: `<span class="badge warn">Unpaid</span>`;
|
||||||
|
|
||||||
|
const pills = [
|
||||||
|
t.cost !== "" ? `<span class="pill">${escapeHtml(moneyLabel(t.cost))}</span>` : "",
|
||||||
|
t.phone ? `<span class="pill">${escapeHtml(t.phone)}</span>` : "",
|
||||||
|
(t.startDate || t.endDate)
|
||||||
|
? `<span class="pill">${escapeHtml((t.startDate || "") + (t.endDate ? " \u2192 " + t.endDate : ""))}</span>`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean).join("");
|
||||||
|
|
||||||
|
const addr = t.address ? `<div class="muted small" style="margin-top:6px">${escapeHtml(t.address)}</div>` : "";
|
||||||
|
|
||||||
|
const mapsBtn = t.address
|
||||||
|
? `<a class="btn" href="${mapsLink(t.address)}" target="_blank" rel="noopener noreferrer">Maps</a>`
|
||||||
|
: `<button class="btn" disabled>Maps</button>`;
|
||||||
|
|
||||||
|
const venue = safeUrl(t.venueWebsite);
|
||||||
|
const venueBtn = venue
|
||||||
|
? `<a class="btn" href="${venue}" target="_blank" rel="noopener noreferrer">Venue</a>`
|
||||||
|
: `<button class="btn" disabled>Venue</button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-left">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<div class="item-title">${escapeHtml(t.name)}</div>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
${pills ? `<div class="pills">${pills}</div>` : ""}
|
||||||
|
${addr}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
${mapsBtn}
|
||||||
|
${venueBtn}
|
||||||
|
<button class="btn" data-action="trip_toggle_paid" data-id="${t.id}">Toggle Paid</button>
|
||||||
|
<button class="btn danger" data-action="trip_remove" data-id="${t.id}">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}</div>`
|
||||||
|
: `<div class="panel"><div class="muted">No trips yet.</div></div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="grid2">
|
||||||
|
<label><span>Item</span><input id="${opts.id}_label" placeholder="${escapeHtml(opts.placeholder || "")}"></label>
|
||||||
|
${opts.allowStore ? `<label><span>Store</span><input id="${opts.id}_store" placeholder="Walmart / HEB / Amazon"></label>` : `<div></div>`}
|
||||||
|
<label style="grid-column:1 / -1"><span>Notes (optional)</span><textarea id="${opts.id}_notes"></textarea></label>
|
||||||
|
${opts.extraForm ? opts.extraForm() : ""}
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="margin-top:12px">
|
||||||
|
<button class="btn primary" id="${opts.id}_add">Add</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const list = items.length
|
||||||
|
? `<div class="items">${
|
||||||
|
items
|
||||||
|
.map((x) => {
|
||||||
|
// NOTE: no "Notes" pill; notes render as plain text below.
|
||||||
|
const pills = [
|
||||||
|
x.store ? `<span class="pill">Store: ${escapeHtml(x.store)}</span>` : "",
|
||||||
|
x.partsNeeded ? `<span class="pill">Parts: ${escapeHtml(x.partsNeeded)}</span>` : "",
|
||||||
|
typeof x.purchased === "boolean"
|
||||||
|
? `<span class="pill">${x.purchased ? "Parts purchased" : "Parts NOT purchased"}</span>`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-left">
|
||||||
|
<input type="checkbox" data-action="${opts.id}_toggle" data-id="${x.id}" ${x.done ? "checked" : ""} style="margin-top:3px">
|
||||||
|
<div>
|
||||||
|
<div class="item-title ${x.done ? "done" : ""}">${escapeHtml(x.label || "")}</div>
|
||||||
|
${pills ? `<div class="pills">${pills}</div>` : ""}
|
||||||
|
${x.notes ? `<div class="muted small" style="margin-top:6px">${escapeHtml(x.notes)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" data-action="${opts.id}_edit" data-id="${x.id}">Edit</button>
|
||||||
|
<button class="btn danger" data-action="${opts.id}_remove" data-id="${x.id}">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}</div>`
|
||||||
|
: `<div class="muted">No items yet.</div>`;
|
||||||
|
|
||||||
|
return panel(opts.title, opts.subtitle, addForm + "<hr />" + 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: () => `
|
||||||
|
<label><span>Parts needed</span><input id="fixes_parts" placeholder="Part name / SKU / link"></label>
|
||||||
|
<label style="display:flex;gap:10px;align-items:center;margin-top:26px">
|
||||||
|
<input id="fixes_purchased" type="checkbox" style="width:auto">
|
||||||
|
<span class="muted">Parts purchased?</span>
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
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...", "", `<div class="muted">Please wait.</div>`);
|
||||||
|
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();
|
||||||
|
})();
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Deny all access to the data directory
|
||||||
|
Require all denied
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"schema": 1,
|
||||||
|
"updatedAt": "2026-04-21T06:29:38+00:00"
|
||||||
|
},
|
||||||
|
"trips": [],
|
||||||
|
"shopping": [],
|
||||||
|
"preTripTodos": [],
|
||||||
|
"preTripChecklist": [],
|
||||||
|
"upgrades": [],
|
||||||
|
"fixes": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$loggedIn = !empty($_SESSION['tt_logged_in']);
|
||||||
|
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Trailer Trekker</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php if (!$loggedIn): ?>
|
||||||
|
<div class="page">
|
||||||
|
<div class="card auth">
|
||||||
|
<h1>Trailer Trekker</h1>
|
||||||
|
<p class="muted">Login</p>
|
||||||
|
|
||||||
|
<form class="form" method="post" action="/api/login.php">
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<?php if (!empty($_GET['err'])): ?>
|
||||||
|
<div class="error">Invalid username or password.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<button class="btn primary" type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted small">
|
||||||
|
Tip: This is a single-user site; credentials live in <code>/var/www/TRAILER_TREKKER/private/config.php</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="title">Trailer Trekker</div>
|
||||||
|
<div class="subtitle">Trips • Lists • Checklists • Upgrades • Fixes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btnExport" class="btn">Export</button>
|
||||||
|
<label class="btn file-btn">
|
||||||
|
Import
|
||||||
|
<input id="fileImport" type="file" accept="application/json" hidden />
|
||||||
|
</label>
|
||||||
|
<button id="btnLogout" class="btn danger">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<nav class="tabs" id="tabs"></nav>
|
||||||
|
<section id="content" class="content"></section>
|
||||||
|
<footer class="footer">
|
||||||
|
<span id="status" class="muted"></span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+220
@@ -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 */
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user