jenot/priv/static/js/jenot.js
Adrian Gruntkowski 909892ded9 WIP
2025-11-09 01:32:49 +01:00

357 lines
9.3 KiB
JavaScript

import "./service-worker-init.js";
import { renderText } from "./dom.js";
import { SyncedNoteStore } from "./synced-store.js";
import {
notificationsAvailable,
authorizeNotifications,
notificationsEnabled,
sendNotification,
} from "./notifications.js";
import "./components.js";
import { reminderLabel } from "./reminders.js";
import { Router } from "./router.js";
// Cookie presence determines login state
const isLoggedIn = !!document.cookie
.split("; ")
.find((row) => row.startsWith("jenot_pub="));
// Notes storage configuration.
// The storage is a combination of IndexedDB + network sync.
// Network sync is only enabled is user is logged in.
const endpoint = isLoggedIn ? "/" : null;
const Notes = new SyncedNoteStore("jenot-app", endpoint);
let view = "home";
const routes = [
{
path: "index.html#home",
callback: () => {
view = "home";
render();
},
},
{
path: "index.html#reminders",
callback: () => {
view = "reminders";
render();
},
},
{ path: "*", callback: (router) => router.redirect("index.html#home") },
];
async function resetApp() {
await window.navigator.serviceWorker
.getRegistration()
.then((r) => r.unregister());
await caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))));
window.location.reload();
}
document.querySelector("#reset-app").addEventListener("click", resetApp);
const URL_PARAMS = new URLSearchParams(window.location.search);
await Notes.init();
// Reset metadata to force full sync
if (URL_PARAMS.has("reset-meta")) {
history.replaceState(
null,
"",
location.href.replace("&reset-meta", "").replace("?reset-meta", ""),
);
await Notes.setMeta({ lastSync: null });
}
// Very rudimentary periodic sync. It will be refactored into a more real-time
// solution using either websocket or long-polling, so that server can notify about
// new data to sync.
const sync = async () => {
await Notes.sync();
Notes.saveStorage();
};
if (isLoggedIn) {
setInterval(sync, 5000);
}
// New account provisioning and login/logout actions
const newAccountForm = document.querySelector("#new-account-form");
const loginLink = document.querySelector("#login-link");
const logoutForm = document.querySelector("#logout-form");
if (!isLoggedIn) {
loginLink.classList.remove("hidden");
newAccountForm.classList.remove("hidden");
} else {
logoutForm.classList.remove("hidden");
}
// Notifications API test - to be reused for push notifications later on
if (notificationsAvailable()) {
document.querySelector("#notifications-pane").classList.remove("hidden");
const notificationsButton = document.querySelector("#enable-notifications");
const notificationsTestButton = document.querySelector("#test-notifications");
if (!notificationsEnabled()) {
notificationsButton.classList.remove("hidden");
notificationsButton.addEventListener("click", () => {
authorizeNotifications(() => notificationsButton.classList.add("hidden"));
});
}
notificationsTestButton.addEventListener("click", () => {
setTimeout(() => {
sendNotification("reminder", "This is a test reminder!");
}, 8000);
});
}
// Search
let filter = "";
const search = document.querySelector("#search");
search.addEventListener("input", (e) => {
filter = e.target.value.trim().toLowerCase();
Notes.saveStorage();
});
function filterNotes(notes) {
if (filter !== "") {
return notes.filter(
(n) =>
n.title.toLowerCase().indexOf(filter) > -1 ||
textOf(n.content).toLowerCase().indexOf(filter) > -1,
);
}
return notes;
}
function textOf(content) {
if (typeof content === "object") {
return content.map((n) => n.content).join("");
} else {
return content;
}
}
// There are two note-form component instances - one for
// composing new notes and another one for editing existing notes.
const newNote = document.querySelector("#new-note");
const editNote = document.querySelector("#edit-note");
const editNoteDialog = document.querySelector("#edit-note-dialog");
// Load draft into new note
newNote.load(structuredClone(await Notes.getDraft()));
// Each save event originating from storage triggers a re-render
// of notes list.
Notes.addEventListener("save", render.bind(this));
// Initial notes render and initial sync.
render();
if (isLoggedIn) {
sync();
}
// note-form component specific event handlers
newNote.addEventListener("addNote", async (e) => {
await Notes.add(e.detail);
await Notes.clearDraft();
Notes.saveStorage();
});
newNote.addEventListener("updateDraft", async (e) => {
await Notes.setDraft(e.detail);
});
newNote.addEventListener("deleteNote", async (e) => {
await Notes.clearDraft();
});
editNote.addEventListener("updateNoteInProgress", async (e) => {
const note = e.detail;
note.updated = Date.now();
await Notes.update(note, true);
});
editNote.addEventListener("updateNote", async (e) => {
await Notes.update(e.detail);
Notes.saveStorage();
closeModal(editNoteDialog);
});
editNote.addEventListener("deleteNote", async (e) => {
await Notes.remove(e.detail);
Notes.saveStorage();
closeModal(editNoteDialog);
});
// The notes rendering routine is optimized to replace only
// nodes that actually changed.
let currentNotes = {};
function notesEqual(note1, note2) {
return note1.id === note2.id && note1.updated === note2.updated;
}
async function renderReminders() {
const allNotes = await Notes.all();
const reminderNotes = allNotes.filter((n) => n.reminder?.enabled);
const notes = filterNotes(reminderNotes).toSorted(
(a, b) => b.reminder.date - a.reminder.date,
);
const notesContainer = document.querySelector("#reminders");
}
async function renderHome() {
const allNotes = await Notes.all();
const notes = filterNotes(allNotes);
const notesContainer = document.querySelector("#notes");
let previousId = null;
let notePrecedence = {};
const ids = [];
notes.forEach((n) => {
notePrecedence[n.id] = previousId;
ids.push(n.id);
previousId = n.id;
});
Object.keys(currentNotes)
.filter((id) => !ids.includes(id))
.forEach((id) => {
delete currentNotes[id];
document.getElementById(id).remove();
});
notes.forEach((note) => {
const existingNote = currentNotes[note.id];
if (!existingNote) {
const noteElement = renderNote(note);
const beforeId = notePrecedence[note.id];
if (!beforeId) {
notesContainer.prepend(noteElement);
} else {
const before = document.getElementById(beforeId);
if (before) {
before.after(noteElement);
} else {
notesContainer.prepend(noteElement);
}
}
} else if (!notesEqual(existingNote, note)) {
const noteElement = renderNote(note);
const existing = document.getElementById(note.id);
existing.replaceWith(noteElement);
}
});
currentNotes = {};
notes.forEach((n) => (currentNotes[n.id] = structuredClone(n)));
}
async function render() {
switch (view) {
case "home":
renderHome();
break;
case "reminders":
renderReminders();
break;
default:
renderError();
}
}
function openModal(modal) {
document.body.style.top = `-${window.scrollY}px`;
document.body.style.position = "fixed";
modal.showModal();
}
function closeModal(modal) {
modal.close();
const scrollY = document.body.style.top;
document.body.style.position = "";
document.body.style.top = "";
window.scrollTo(0, parseInt(scrollY || "0") * -1);
}
function renderNote(note) {
const container = document.createElement("div");
container.id = note.id;
container.classList.add("note");
container.classList.add("readonly");
if (note.title && note.title !== "") {
const title = document.createElement("p");
title.classList.add("title");
title.replaceChildren(...renderText(note.title));
container.appendChild(title);
}
if (note.type === "note") {
const body = document.createElement("p");
body.classList.add("body");
body.replaceChildren(...renderText(note.content));
container.appendChild(body);
} else if (note.type === "tasklist") {
const list = document.createElement("ul");
list.classList.add("body");
note.content.forEach((task) => {
const item = document.createElement("li");
if (task.checked) {
item.classList.add("checked");
}
const check = document.createElement("p");
check.classList.add("checkbox");
check.textContent = task.checked ? "☑" : "☐";
item.appendChild(check);
const itemContent = document.createElement("p");
itemContent.classList.add("content");
itemContent.replaceChildren(...renderText(task.content));
item.appendChild(itemContent);
list.append(item);
});
container.appendChild(list);
}
if (note.reminder?.enabled) {
const labelText = reminderLabel(note.reminder);
const reminderBadge = document.createElement("span");
reminderBadge.classList.add("reminder-label");
reminderBadge.textContent = `${labelText}`;
container.appendChild(reminderBadge);
}
container.addEventListener("click", async (e) => {
if (e.target.tagName !== "A") {
const note = await Notes.get(container.id);
editNote.load(structuredClone(note));
openModal(editNoteDialog);
}
});
return container;
}
const router = Router.init(routes);