641 lines
20 KiB
JavaScript
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("<", "<")
|
|
.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();
|
|
})(); |