Files
2026-06-25 21:39:21 +00:00

641 lines
20 KiB
JavaScript

(() => {
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("<", "&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();
})();