Shiawassee Ranch

Digital Log - shared family notes
...
Posting as
Workspace
All
Published
Open actions
Photos
Total
-
Published
-
Open actions
-
Photos
-
tag is in and not blocked."); } const sb = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY); /*** State ***/ let notes = []; let editingId = null; let editingExistingPhotos = []; // urls let newPhotoFiles = []; // File[] let actions = []; // {id,text,done} /*** Connectivity badge ***/ function updateConn() { const online = navigator.onLine; dot.className = "dot " + (online ? "ok" : "bad"); connText.textContent = online ? "Online" : "Offline"; } window.addEventListener("online", updateConn); window.addEventListener("offline", updateConn); updateConn(); /*** Chips ***/ chips.forEach(c => c.onclick = () => { chips.forEach(x => x.classList.remove("on")); c.classList.add("on"); activeChip = c.dataset.chip; render(); }); /*** Search ***/ q.addEventListener("input", render); /*** Offline queue (IndexedDB) ***/ const DB_NAME = "sr_log_db"; const DB_STORE = "offline_queue"; let idb; function idbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; db.createObjectStore(DB_STORE, { keyPath: "qid" }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function queueWrite(op) { if (!idb) idb = await idbOpen(); const tx = idb.transaction(DB_STORE, "readwrite"); tx.objectStore(DB_STORE).put(op); return new Promise((res, rej) => { tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); } async function drainQueue() { if (!navigator.onLine) return; if (!idb) idb = await idbOpen(); const tx = idb.transaction(DB_STORE, "readonly"); const store = tx.objectStore(DB_STORE); const ops = []; await new Promise((res) => { store.openCursor().onsuccess = (e) => { const cur = e.target.result; if (!cur) return res(); ops.push(cur.value); cur.continue(); }; }); for (const op of ops) { try { if (op.kind === "upsert") await sb.from("notes").upsert(op.payload); if (op.kind === "delete") await sb.from("notes").delete().eq("id", op.id).eq("workspace_id", WORKSPACE_ID); const t2 = idb.transaction(DB_STORE, "readwrite"); t2.objectStore(DB_STORE).delete(op.qid); await new Promise((res) => (t2.oncomplete = res)); } catch (e) { break; } } await loadNotes(); } window.addEventListener("online", drainQueue); /*** Realtime subscription ***/ let channel = null; function subscribeRealtime() { if (channel) sb.removeChannel(channel); channel = sb.channel("sr_notes_" + WORKSPACE_ID) .on("postgres_changes", { event: "*", schema: "public", table: "notes", filter: `workspace_id=eq.${WORKSPACE_ID}` }, () => loadNotes()) .subscribe(); } /*** Load + render ***/ function passFilters(n) { if (activeChip === "published" && !n.is_published) return false; if (activeChip === "open") { const open = (n.actions || []).filter(a => !a.done).length; if (open === 0) return false; } if (activeChip === "photos") { if (!n.photo_urls || n.photo_urls.length === 0) return false; } const query = (q.value || "").trim().toLowerCase(); if (!query) return true; const hay = [ n.title || "", n.body || "", n.author || "", n.location || "", n.entry_type || "", (n.tags || []).join(" ") ].join(" ").toLowerCase(); return hay.includes(query); } function computeStats() { sTotal.textContent = notes.length; sPub.textContent = notes.filter(n => n.is_published).length; sOpen.textContent = notes.reduce((acc, n) => acc + (n.actions || []).filter(a => !a.done).length, 0); sPhotos.textContent = notes.reduce((acc, n) => acc + ((n.photo_urls || []).length), 0); } function render() { list.innerHTML = ""; const filtered = notes.filter(passFilters); empty.style.display = filtered.length ? "none" : "block"; for (const n of filtered) { const div = document.createElement("div"); div.className = "note " + (n.is_published ? "published" : ""); const when = n.event_at ? new Date(n.event_at).toLocaleString() : ""; const openActions = (n.actions || []).filter(a => !a.done).length; const photos = (n.photo_urls || []).slice(0,3); div.innerHTML = `
${escapeHtml(n.title || "Log Entry")}
${escapeHtml(when)} - ${escapeHtml(n.author || "")}
${escapeHtml(n.body || "")}
${n.is_published ? "Published" : "Draft"} ${n.location ? `${escapeHtml(n.location)}` : ""} ${n.entry_type ? `${escapeHtml(n.entry_type)}` : ""} ${openActions ? `${openActions} open` : ""} ${(n.tags || []).slice(0,6).map(t => `#${escapeHtml(t)}`).join("")}
${photos.length ? `
${photos.map(u => ``).join("")}
` : ""} `; div.querySelector("button").onclick = () => openEdit(n); list.appendChild(div); } } async function loadNotes() { const { data, error } = await sb .from("notes") .select("*") .eq("workspace_id", WORKSPACE_ID) .order("event_at", { ascending: false }) .limit(500); if (!error) notes = data || []; computeStats(); render(); } /*** Modal logic ***/ function openCreate() { editingId = null; editingExistingPhotos = []; newPhotoFiles = []; actions = []; mTitle.textContent = "New Log Entry"; delBtn.style.display = "none"; clearModal(); pub.checked = true; renderActions(); renderPhotoPreview(); openModal(); } function openEdit(n) { editingId = n.id; mTitle.textContent = "Edit Log Entry"; delBtn.style.display = "inline-block"; msg.textContent = ""; saveStatus.textContent = "-"; t.value = n.title || ""; b.value = n.body || ""; loc.value = n.location || ""; typ.value = n.entry_type || ""; tags.value = (n.tags || []).join(", "); pub.checked = !!n.is_published; actions = [...(n.actions || [])]; editingExistingPhotos = [...(n.photo_urls || [])]; newPhotoFiles = []; renderActions(); renderPhotoPreview(); openModal(); } function openModal() { modal.classList.add("on"); document.body.style.overflow="hidden"; } function closeModal() { modal.classList.remove("on"); document.body.style.overflow=""; clearModal(); } function clearModal() { t.value=""; b.value=""; loc.value=""; typ.value=""; tags.value=""; photo.value=""; photoPrev.innerHTML=""; photoCount.textContent="0 selected"; aIn.value=""; aList.innerHTML=""; msg.textContent=""; saveStatus.textContent="-"; } /*** Actions ***/ aIn.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); const text = (aIn.value || "").trim(); if (!text) return; actions.push({ id: cryptoRandomId(), text, done: false }); aIn.value=""; renderActions(); } }); function renderActions() { aList.innerHTML = ""; if (!actions.length) { aList.innerHTML = `
No action items yet.
`; return; } for (const a of actions) { const row = document.createElement("div"); row.className = "actionItem"; row.innerHTML = ` `; row.querySelector("input").onchange = (ev) => { a.done = ev.target.checked; renderActions(); }; row.querySelector("button").onclick = () => { actions = actions.filter(x => x.id !== a.id); renderActions(); }; aList.appendChild(row); } } /*** Photos ***/ photo.addEventListener("change", async () => { const files = Array.from(photo.files || []); const cap = Math.max(0, 12 - (editingExistingPhotos.length + newPhotoFiles.length)); newPhotoFiles = newPhotoFiles.concat(files.slice(0, cap)); photoCount.textContent = `${newPhotoFiles.length} selected`; renderPhotoPreview(); }); function renderPhotoPreview() { photoPrev.innerHTML = ""; editingExistingPhotos.forEach((url, idx) => { const wrap = document.createElement("div"); wrap.style.position="relative"; wrap.innerHTML = ` `; wrap.querySelector("button").onclick = () => { editingExistingPhotos.splice(idx, 1); renderPhotoPreview(); }; photoPrev.appendChild(wrap); }); newPhotoFiles.forEach((file, idx) => { const objUrl = URL.createObjectURL(file); const wrap = document.createElement("div"); wrap.style.position="relative"; wrap.innerHTML = ` `; wrap.querySelector("button").onclick = () => { newPhotoFiles.splice(idx, 1); photoCount.textContent = `${newPhotoFiles.length} selected`; renderPhotoPreview(); }; photoPrev.appendChild(wrap); }); } /*** Save / Delete ***/ saveBtn.onclick = async () => { msg.textContent = ""; const title = (t.value || "").trim(); const body = (b.value || "").trim(); const location = (loc.value || "").trim(); const entry_type = (typ.value || "").trim(); const tagArr = (tags.value || "").split(",").map(x => x.trim()).filter(Boolean); const is_published = !!pub.checked; if (!body) { msg.textContent = "Please enter what happened (body is required)."; return; } saveBtn.disabled = true; saveBtn.textContent = "Saving..."; saveStatus.textContent = navigator.onLine ? "Saving..." : "Queued (offline)..."; const id = editingId || cryptoRandomId(); const now = new Date().toISOString(); try { let uploadedUrls = []; if (newPhotoFiles.length) { if (!navigator.onLine) throw new Error("You're offline. Save now, then edit later to upload photos."); uploadedUrls = await uploadPhotos(id, newPhotoFiles); } const payload = { id, workspace_id: WORKSPACE_ID, updated_at: now, event_at: now, author: authorName(), title, body, location: location || null, entry_type: entry_type || null, tags: tagArr, is_published, actions, photo_urls: [...editingExistingPhotos, ...uploadedUrls], pending: !navigator.onLine }; if (!editingId) payload.created_at = now; if (navigator.onLine) { const { error } = await sb.from("notes").upsert(payload); if (error) throw error; saveStatus.textContent = "Saved ✅"; await loadNotes(); closeModal(); } else { await queueWrite({ qid: cryptoRandomId(), kind: "upsert", payload }); saveStatus.textContent = "Queued ✅"; notes = [payload, ...notes.filter(n => n.id !== payload.id)]; computeStats(); render(); closeModal(); } } catch (e) { msg.textContent = e?.message || "Save failed."; saveStatus.textContent = "-"; } finally { saveBtn.disabled = false; saveBtn.textContent = "Save"; newPhotoFiles = []; photoCount.textContent = "0 selected"; } }; delBtn.onclick = async () => { if (!editingId) return; if (!confirm("Delete this log entry?")) return; delBtn.disabled = true; delBtn.textContent = "Deleting..."; try { if (navigator.onLine) { const { error } = await sb.from("notes").delete().eq("id", editingId).eq("workspace_id", WORKSPACE_ID); if (error) throw error; await loadNotes(); closeModal(); } else { await queueWrite({ qid: cryptoRandomId(), kind: "delete", id: editingId }); notes = notes.filter(n => n.id !== editingId); computeStats(); render(); closeModal(); } } catch (e) { msg.textContent = e?.message || "Delete failed."; } finally { delBtn.disabled = false; delBtn.textContent = "Delete"; } }; /*** Upload helpers (Worker -> R2) ***/ async function uploadPhotos(noteId, files) { const urls = []; for (const f of files) { const compressed = await compressImageFile(f, 1600, 0.78); const form = new FormData(); form.append("file", compressed); const res = await fetch(`${WORKER_BASE_URL}/upload?workspace_id=${encodeURIComponent(WORKSPACE_ID)}¬e_id=${encodeURIComponent(noteId)}`, { method: "POST", body: form }); if (!res.ok) throw new Error("Photo upload failed."); const out = await res.json(); urls.push(out.url); } return urls; } async function compressImageFile(file, maxDim = 1600, quality = 0.78) { if (!file.type.startsWith("image/")) return file; const img = await fileToImage(file); const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, w, h); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality)); return new File([blob], sanitizeFilename(file.name).replace(/\.(png|webp|heic|heif)$/i, ".jpg"), { type: "image/jpeg" }); } function fileToImage(file) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = URL.createObjectURL(file); }); } /*** Utilities ***/ function escapeHtml(str){ return (str||"").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); } function cryptoRandomId(){ return (crypto?.randomUUID?.() || Math.random().toString(36).slice(2)); } function sanitizeFilename(name){ return String(name||"photo.jpg").replace(/[^a-z0-9._-]/gi,"_"); } /*** Init ***/ (async function init() { await loadNotes(); subscribeRealtime(); await drainQueue(); })();