Files
paliad/frontend/src/client/notes.ts
m caf319e7ee refactor(rename): frontend TSX + client TS files, fetch URLs, nav hrefs
t-paliad-025 Phase 3 — frontend rename pass:

File renames (git mv, preserving history):
  frontend/src/
    akten.tsx               → projects.tsx
    akten-neu.tsx           → projects-new.tsx
    akten-detail.tsx        → projects-detail.tsx
    fristen.tsx             → deadlines.tsx
    fristen-neu.tsx         → deadlines-new.tsx
    fristen-detail.tsx      → deadlines-detail.tsx
    fristen-kalender.tsx    → deadlines-calendar.tsx
    termine.tsx             → appointments.tsx
    termine-neu.tsx         → appointments-new.tsx
    termine-detail.tsx      → appointments-detail.tsx
    termine-kalender.tsx    → appointments-calendar.tsx
    einstellungen.tsx       → settings.tsx
    checklisten*.tsx        → checklists*.tsx
    gerichte.tsx            → courts.tsx
    glossar.tsx             → glossary.tsx

  frontend/src/client/ — same renames, plus notizen.ts → notes.ts.

Render exports renamed (renderAkten → renderProjects, renderFristen →
renderDeadlines, …). build.ts rewired to new names.

Client-side changes:
* fetch() API paths: /api/projekte → /api/projects, /api/fristen →
  /api/deadlines, /api/termine → /api/appointments, /api/notizen →
  /api/notes, /api/gerichte → /api/courts, /api/glossar → /api/glossary,
  /api/dezernate → /api/departments, /api/parteien → /api/parties,
  /api/checklisten → /api/checklists. Legacy /api/akten aliases removed.
* Navigation href/template strings: /akten → /projects, /fristen →
  /deadlines, /termine → /appointments, /einstellungen → /settings,
  /notizen → /notes, /checklisten → /checklists, /gerichte → /courts,
  /glossar → /glossary. Nested paths /neu → /new, /verlauf → /events,
  /kinder → /children, /kalender → /calendar, /dokumente → /documents.
* Interface names in client TS: Frist → Deadline, Termin → Appointment,
  Notiz → Note, Partei → Party, Akte → Project, ProjektMini → ProjectMini,
  Dezernat → Department, DezernatMitglied → DepartmentMember.
* JSON wire-format keys follow backend: projekt_id → project_id, akte_id
  → project_id, frist_id → deadline_id, termin_id → appointment_id,
  akten_event_id → project_event_id, dezernat_id → department_id,
  termin_type → appointment_type.

Go handlers (projects_pages.go, deadlines_pages.go, appointments_pages.go,
checklists.go, courts.go, glossary.go) serve the correctly-named HTML
files from dist/.

Kept German (user-facing i18n + product names):
* i18n keys/strings (src/client/i18n.ts) — DE labels and their keys
* Product names: fristenrechner, kostenrechner, gebuehrentabellen

Build verified: go build / vet / test clean; bun run build clean;
dist/ contains all 26 English-named HTML pages.
2026-04-20 17:44:45 +02:00

467 lines
15 KiB
TypeScript

// Shared polymorphic notes module. Each detail page (akten / fristen /
// termine) renders an empty <div id="notes-container" data-parent-type=…
// data-parent-id=…> and loads this script. initNotes(container) attaches
// the "Add note" form + list rendering; the API base URL is picked from
// the parent type.
//
// Visibility and edit rights are enforced by the server — the UI only
// hides the edit/delete controls when they wouldn't work anyway (non-
// author, non-partner).
import { t, getLang } from "./i18n";
export type NotizParentType = "project" | "frist" | "termin";
export interface Note {
id: string;
project_id?: string | null;
deadline_id?: string | null;
appointment_id?: string | null;
project_event_id?: string | null;
content: string;
created_by?: string | null;
created_at: string;
updated_at: string;
author_name?: string | null;
author_email?: string | null;
}
interface Me {
id: string;
role: string;
}
interface NotesState {
parentType: NotizParentType;
parentId: string;
me: Me | null;
notes: Note[];
listEl: HTMLElement;
emptyEl: HTMLElement;
formEl: HTMLFormElement;
textareaEl: HTMLTextAreaElement;
submitBtn: HTMLButtonElement;
msgEl: HTMLElement;
editingID: string | null;
}
function baseURL(parentType: NotizParentType, parentId: string): string {
switch (parentType) {
case "project":
return `/api/projects/${parentId}/notizen`;
case "frist":
return `/api/deadlines/${parentId}/notizen`;
case "termin":
return `/api/appointments/${parentId}/notizen`;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function authorLabel(n: Note): string {
return n.author_name || n.author_email || t("notizen.unknown_author");
}
function fmtRelative(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diff = Math.max(0, now - then);
const sec = Math.floor(diff / 1000);
const min = Math.floor(sec / 60);
const hr = Math.floor(min / 60);
const day = Math.floor(hr / 24);
const lang = getLang();
if (sec < 45) return t("notizen.time.just_now");
if (min < 2) return lang === "de" ? "vor 1 Minute" : "1 minute ago";
if (min < 60) return lang === "de" ? `vor ${min} Minuten` : `${min} minutes ago`;
if (hr < 2) return lang === "de" ? "vor 1 Stunde" : "1 hour ago";
if (hr < 24) return lang === "de" ? `vor ${hr} Stunden` : `${hr} hours ago`;
if (day < 2) return lang === "de" ? "gestern" : "yesterday";
if (day < 30) return lang === "de" ? `vor ${day} Tagen` : `${day} days ago`;
// > 30 days → absolute date
const d = new Date(iso);
return d.toLocaleDateString(lang === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function canEdit(state: NotesState, n: Note): boolean {
if (!state.me) return false;
return n.created_by === state.me.id;
}
function canDelete(state: NotesState, n: Note): boolean {
if (!state.me) return false;
if (n.created_by === state.me.id) return true;
return state.me.role === "partner" || state.me.role === "admin";
}
function render(state: NotesState) {
state.listEl.innerHTML = "";
if (state.notes.length === 0) {
state.emptyEl.style.display = "";
return;
}
state.emptyEl.style.display = "none";
for (const n of state.notes) {
state.listEl.appendChild(noteCard(state, n));
}
}
function noteCard(state: NotesState, n: Note): HTMLElement {
const card = document.createElement("li");
card.className = "notiz-card";
card.dataset.id = n.id;
const header = document.createElement("div");
header.className = "notiz-card-header";
const author = document.createElement("span");
author.className = "notiz-author";
author.textContent = authorLabel(n);
const time = document.createElement("time");
time.className = "notiz-time";
time.dateTime = n.created_at;
time.textContent = fmtRelative(n.created_at);
time.title = new Date(n.created_at).toLocaleString(getLang() === "de" ? "de-DE" : "en-GB");
header.appendChild(author);
header.appendChild(time);
const actions = document.createElement("div");
actions.className = "notiz-actions";
if (canEdit(state, n)) {
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "notiz-action-btn";
editBtn.setAttribute("aria-label", t("notizen.edit"));
editBtn.title = t("notizen.edit");
editBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>`;
editBtn.addEventListener("click", () => startEdit(state, n));
actions.appendChild(editBtn);
}
if (canDelete(state, n)) {
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "notiz-action-btn notiz-action-danger";
delBtn.setAttribute("aria-label", t("notizen.delete"));
delBtn.title = t("notizen.delete");
delBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>`;
delBtn.addEventListener("click", () => deleteNote(state, n));
actions.appendChild(delBtn);
}
if (actions.children.length > 0) {
header.appendChild(actions);
}
const body = document.createElement("div");
body.className = "notiz-body";
body.innerHTML = linkify(n.content);
card.appendChild(header);
card.appendChild(body);
if (n.updated_at && n.updated_at !== n.created_at) {
const edited = document.createElement("span");
edited.className = "notiz-edited";
edited.textContent = t("notizen.edited");
edited.title = new Date(n.updated_at).toLocaleString(getLang() === "de" ? "de-DE" : "en-GB");
card.appendChild(edited);
}
return card;
}
// Plaintext → HTML with URL auto-linking. Escapes first, then linkifies.
function linkify(content: string): string {
const escaped = esc(content);
// Match URLs outside existing markup. Simple pattern — escaped content has
// no tags, so we only need to avoid breaking &amp; etc.
return escaped.replace(/(https?:\/\/[^\s<]+)/g, (url) => {
const clean = url.replace(/[.,;:!?)]+$/, "");
const trailing = url.slice(clean.length);
return `<a href="${clean}" target="_blank" rel="noopener noreferrer">${clean}</a>${trailing}`;
}).replace(/\n/g, "<br>");
}
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return (await resp.json()) as Me;
} catch {
return null;
}
}
async function loadNotes(state: NotesState) {
try {
const resp = await fetch(baseURL(state.parentType, state.parentId));
if (!resp.ok) {
state.notes = [];
render(state);
return;
}
const data = (await resp.json()) as Note[];
state.notes = Array.isArray(data) ? data : [];
} catch {
state.notes = [];
}
render(state);
}
function showMsg(state: NotesState, kind: "ok" | "error", text: string) {
state.msgEl.textContent = text;
state.msgEl.className = `form-msg ${kind === "ok" ? "form-msg-ok" : "form-msg-error"}`;
}
function clearMsg(state: NotesState) {
state.msgEl.textContent = "";
state.msgEl.className = "form-msg";
}
async function submitForm(ev: Event, state: NotesState) {
ev.preventDefault();
const content = state.textareaEl.value.trim();
if (!content) {
showMsg(state, "error", t("notizen.error.empty"));
return;
}
state.submitBtn.disabled = true;
clearMsg(state);
if (state.editingID) {
await saveEdit(state, state.editingID, content);
} else {
await addNote(state, content);
}
state.submitBtn.disabled = false;
}
async function addNote(state: NotesState, content: string) {
// Optimistic add: insert a placeholder, replace with server response on success.
const tempID = `temp-${Date.now()}`;
const placeholder: Note = {
id: tempID,
content,
created_by: state.me?.id ?? null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
author_name: null,
author_email: null,
};
state.notes = [placeholder, ...state.notes];
render(state);
state.textareaEl.value = "";
try {
const resp = await fetch(baseURL(state.parentType, state.parentId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!resp.ok) {
// Revert: remove placeholder
state.notes = state.notes.filter((n) => n.id !== tempID);
render(state);
state.textareaEl.value = content;
const data = await resp.json().catch(() => ({}) as { error?: string });
showMsg(state, "error", data.error || t("notizen.error.generic"));
return;
}
const saved = (await resp.json()) as Note;
state.notes = state.notes.map((n) => (n.id === tempID ? saved : n));
render(state);
} catch {
state.notes = state.notes.filter((n) => n.id !== tempID);
render(state);
state.textareaEl.value = content;
showMsg(state, "error", t("notizen.error.generic"));
}
}
function startEdit(state: NotesState, n: Note) {
state.editingID = n.id;
state.textareaEl.value = n.content;
state.textareaEl.focus();
state.submitBtn.textContent = t("notizen.save");
state.formEl.classList.add("notiz-form-editing");
// Add a cancel affordance inline
let cancel = state.formEl.querySelector<HTMLButtonElement>(".notiz-cancel-btn");
if (!cancel) {
cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn-cancel notiz-cancel-btn";
cancel.textContent = t("notizen.cancel");
cancel.addEventListener("click", () => cancelEdit(state));
state.submitBtn.parentElement!.insertBefore(cancel, state.submitBtn);
}
}
function cancelEdit(state: NotesState) {
state.editingID = null;
state.textareaEl.value = "";
state.submitBtn.textContent = t("notizen.submit");
state.formEl.classList.remove("notiz-form-editing");
const cancel = state.formEl.querySelector<HTMLButtonElement>(".notiz-cancel-btn");
if (cancel) cancel.remove();
clearMsg(state);
}
async function saveEdit(state: NotesState, id: string, content: string) {
const prev = state.notes.find((n) => n.id === id);
if (!prev) return;
const prevContent = prev.content;
// Optimistic
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content, updated_at: new Date().toISOString() } : n));
render(state);
cancelEdit(state);
try {
const resp = await fetch(`/api/notes/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!resp.ok) {
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content: prevContent } : n));
render(state);
const data = await resp.json().catch(() => ({}) as { error?: string });
showMsg(state, "error", data.error || t("notizen.error.generic"));
return;
}
const saved = (await resp.json()) as Note;
state.notes = state.notes.map((n) => (n.id === id ? saved : n));
render(state);
} catch {
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content: prevContent } : n));
render(state);
showMsg(state, "error", t("notizen.error.generic"));
}
}
async function deleteNote(state: NotesState, n: Note) {
if (!confirm(t("notizen.delete.confirm"))) return;
const prev = state.notes;
state.notes = state.notes.filter((x) => x.id !== n.id);
render(state);
try {
const resp = await fetch(`/api/notes/${n.id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
state.notes = prev;
render(state);
const data = await resp.json().catch(() => ({}) as { error?: string });
alert(data.error || t("notizen.error.generic"));
}
} catch {
state.notes = prev;
render(state);
alert(t("notizen.error.generic"));
}
}
function buildUI(container: HTMLElement, state: NotesState) {
container.innerHTML = "";
const form = document.createElement("form");
form.className = "notiz-form";
form.autocomplete = "off";
const textarea = document.createElement("textarea");
textarea.className = "notiz-textarea";
textarea.rows = 3;
textarea.placeholder = t("notizen.placeholder");
textarea.setAttribute("data-i18n-placeholder", "notizen.placeholder");
const actions = document.createElement("div");
actions.className = "notiz-form-actions";
const submit = document.createElement("button");
submit.type = "submit";
submit.className = "btn-primary btn-cta-lime btn-small";
submit.textContent = t("notizen.submit");
const msg = document.createElement("p");
msg.className = "form-msg";
actions.appendChild(submit);
form.appendChild(textarea);
form.appendChild(actions);
form.appendChild(msg);
const list = document.createElement("ul");
list.className = "notiz-list";
const empty = document.createElement("p");
empty.className = "notiz-empty";
empty.textContent = t("notizen.empty");
empty.setAttribute("data-i18n", "notizen.empty");
empty.style.display = "none";
container.appendChild(form);
container.appendChild(list);
container.appendChild(empty);
state.formEl = form;
state.textareaEl = textarea;
state.submitBtn = submit;
state.msgEl = msg;
state.listEl = list;
state.emptyEl = empty;
form.addEventListener("submit", (ev) => submitForm(ev, state));
textarea.addEventListener("keydown", (ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
form.requestSubmit();
}
});
}
export async function initNotes(
container: HTMLElement,
parentType: NotizParentType,
parentId: string,
): Promise<void> {
const state: NotesState = {
parentType,
parentId,
me: null,
notes: [],
listEl: document.createElement("ul"),
emptyEl: document.createElement("p"),
formEl: document.createElement("form"),
textareaEl: document.createElement("textarea"),
submitBtn: document.createElement("button"),
msgEl: document.createElement("p"),
editingID: null,
};
buildUI(container, state);
state.me = await loadMe();
await loadNotes(state);
}
// Auto-init if a container element with data attributes is present. Any
// page that embeds a <div id="notes-container" data-parent-type=…
// data-parent-id=…> gets the component for free — no per-page wiring.
export function autoInitNotes() {
const el = document.getElementById("notes-container");
if (!el) return;
const parentType = el.getAttribute("data-parent-type") as NotizParentType | null;
const parentId = el.getAttribute("data-parent-id");
if (!parentType || !parentId) return;
void initNotes(el, parentType, parentId);
}