initial commit

This commit is contained in:
2026-06-25 21:39:21 +00:00
commit 26fa6ea6b9
10 changed files with 1110 additions and 0 deletions
+54
View File
@@ -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;
}
+24
View File
@@ -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]);
+45
View File
@@ -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]);
+20
View File
@@ -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;
+17
View File
@@ -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;
+641
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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
View File
@@ -0,0 +1,2 @@
# Deny all access to the data directory
Require all denied
+12
View File
@@ -0,0 +1,12 @@
{
"meta": {
"schema": 1,
"updatedAt": "2026-04-21T06:29:38+00:00"
},
"trips": [],
"shopping": [],
"preTripTodos": [],
"preTripChecklist": [],
"upgrades": [],
"fixes": []
}
+75
View File
@@ -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
View File
@@ -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 */
}