diff --git a/backend/main.py b/backend/main.py index 1434278..08f5f7b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,6 +80,7 @@ async def me(request: Request) -> dict: ip = client_ip(request) return { "user": current_user(request), + "common_space": settings.common_space, "local": auth.is_lan(ip), "authed": not auth.is_lan(ip) and bool( @@ -140,8 +141,8 @@ async def list_notes(request: Request, _: None = Depends(require_access)): @app.get("/api/notes/recent") -async def recent(_: None = Depends(require_access)): - return await notes.list_recent() +async def recent(request: Request, _: None = Depends(require_access)): + return await notes.list_recent(current_user(request)) @app.get("/api/notes/{note_id}") diff --git a/backend/notes.py b/backend/notes.py index 9beb97f..dca5997 100644 --- a/backend/notes.py +++ b/backend/notes.py @@ -243,15 +243,15 @@ async def list_notes(username: str, limit: int = 100) -> list[dict]: return notes -async def list_recent(limit: int = 30) -> list[dict]: +async def list_recent(username: str, limit: int = 30) -> list[dict]: async with aiosqlite.connect(settings.database_path) as db: db.row_factory = aiosqlite.Row rows = await db.execute( "SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username" " FROM notes n JOIN users u ON u.id = n.user_id" - " WHERE n.deleted_at IS NULL" + " WHERE n.deleted_at IS NULL AND u.name IN (?, ?)" " ORDER BY n.updated_at DESC LIMIT ?", - (limit,), + (username, settings.common_space, limit), ) notes = [dict(r) for r in await rows.fetchall()] for note in notes: diff --git a/static/app.css b/static/app.css index 5e422a9..d9217d4 100644 --- a/static/app.css +++ b/static/app.css @@ -273,6 +273,23 @@ body { .note-meta { color: var(--muted); font-size: 12px; margin-top: 2px; } .note-tags { color: var(--primary); font-size: 12px; margin-top: 4px; } +.note-common { border-left: 3px solid var(--secondary); } +.common-badge { + display: inline-block; + background: color-mix(in srgb, var(--secondary) 18%, transparent); + color: var(--secondary); + border-radius: 10px; + padding: 0 7px; + font-size: 11px; + margin-right: 2px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .04em; +} + +.common-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); cursor: pointer; } +.common-toggle input { accent-color: var(--secondary); cursor: pointer; } + .tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; } .tag-chip { background: color-mix(in srgb, var(--primary) 12%, transparent); diff --git a/static/app.js b/static/app.js index a805921..80856e1 100644 --- a/static/app.js +++ b/static/app.js @@ -6,7 +6,7 @@ const root = document.getElementById("root"); const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" }); marked.setOptions({ gfm: true, breaks: false }); -const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home", space: null, userNames: null }; +const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home", space: null, userNames: null, commonSpace: "common", isCommon: false }; const THEMES = [ { id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6", "#8b5cf6"] }, @@ -157,6 +157,7 @@ async function boot() { try { const me = await api("GET", "/api/me"); state.user = me.user; state.local = me.local; state.authed = me.authed; + state.commonSpace = me.common_space || "common"; } catch { return; } if (!state.local && !state.authed) { showLogin(); return; } showHome(); @@ -230,7 +231,7 @@ function showHelp() { el("p", { html: 'Use the toolbar to format text with bold, italic, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }), el("h2", { text: "Notes & sharing" }), - el("p", { html: 'Notes are plain Markdown files. A note lives in a space (a user), chosen with the dropdown at the top of the editor. Changing the space moves the note to that user.' }), + el("p", { html: 'Notes are plain Markdown files. A note lives in a space (a user), chosen with the dropdown at the top of the editor. Tick the common checkbox to save it to the shared space everyone can see. Changing the space moves the note to that user.' }), el("p", { html: 'Use Attach to upload a file and insert a link to it. History shows every change, and Delete soft-deletes a note.' }), ]), ]); @@ -366,6 +367,7 @@ async function populateUsers() { const names = users.map((u) => u.name); state.userNames = names; for (const n of names) { + if (n === state.commonSpace) continue; sel.appendChild(el("option", { value: n, text: n })); } sel.value = state.user; @@ -402,9 +404,13 @@ function noteList(notes) { const ul = el("ul", { class: "note-list" }); for (const n of notes) { const tags = (n.tags || []).map((t) => `#${t}`).join(" "); - ul.appendChild(el("li", { class: "note-item", onclick: () => openNote(n.id) }, [ + const isCommon = n.username === state.commonSpace; + const meta = el("div", { class: "note-meta" }); + if (isCommon) meta.appendChild(el("span", { class: "common-badge", text: "common" })); + meta.appendChild(document.createTextNode(`${n.username} · ${relTime(n.updated_at)}`)); + ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [ el("div", { class: "note-title", text: n.title || "Untitled" }), - el("div", { class: "note-meta", text: `${n.username} · ${relTime(n.updated_at)}` }), + meta, el("div", { class: "note-tags", text: tags }), ])); } @@ -466,13 +472,21 @@ function renderEditor(body, owner) { const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null; const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null; + const realUsers = (state.userNames || [state.user]).filter((n) => n !== state.commonSpace); const spaceSel = el("select", { id: "space-select", class: "user-select", onchange: (e) => { state.space = e.target.value; } }); - for (const n of (state.userNames || [state.user])) { + for (const n of realUsers) { spaceSel.appendChild(el("option", { value: n, text: n })); } - const initialSpace = owner || state.space || state.user; - spaceSel.value = initialSpace; - state.space = initialSpace; + state.isCommon = owner === state.commonSpace; + state.space = owner && owner !== state.commonSpace ? owner : (state.user || realUsers[0]); + spaceSel.value = state.space; + + const commonCb = el("input", { type: "checkbox", id: "common-check" }); + commonCb.checked = state.isCommon; + commonCb.addEventListener("change", () => { state.isCommon = commonCb.checked; }); + const commonLabel = el("label", { class: "common-toggle", title: "Save to the shared common space" }, [ + commonCb, el("span", { text: "common" }), + ]); const toolbar = el("div", { class: "toolbar" }, [ toolbarBtn("B", "bold"), toolbarBtn("I", "italic"), @@ -494,7 +508,7 @@ function renderEditor(body, owner) { const attach = el("button", { class: "btn", text: "Attach", onclick: () => fileInput.click() }); const wrap = el("div", { class: "editor-wrap" }, [ - el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]), + el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, commonLabel, save]), toolbar, editorArea, hint, @@ -538,13 +552,14 @@ function runToolbar(action) { async function persistNote() { const html = state.editor.getHTML(); const body = turndownService.turndown(html); + const username = state.isCommon ? state.commonSpace : state.space; const status = document.querySelector(".status"); try { let note; if (state.noteId) { - note = await api("PUT", "/api/notes/" + state.noteId, { body, username: state.space }); + note = await api("PUT", "/api/notes/" + state.noteId, { body, username }); } else { - note = await api("POST", "/api/notes", { body, username: state.space }); + note = await api("POST", "/api/notes", { body, username }); state.noteId = note.id; } state.editor.commands.setContent(marked.parse(note.body)); @@ -574,7 +589,7 @@ async function uploadAttachment(input) { if (!file) return; const fd = new FormData(); fd.append("file", file); - fd.append("username", state.space); + fd.append("username", state.isCommon ? state.commonSpace : state.space); const status = document.querySelector(".status"); try { const resp = await fetch("/api/attachments", { method: "POST", body: fd });