user spaces

This commit is contained in:
2026-09-23 14:13:08 -07:00
parent 163cad4f5f
commit 67769c50d8
4 changed files with 50 additions and 17 deletions

View File

@@ -80,6 +80,7 @@ async def me(request: Request) -> dict:
ip = client_ip(request) ip = client_ip(request)
return { return {
"user": current_user(request), "user": current_user(request),
"common_space": settings.common_space,
"local": auth.is_lan(ip), "local": auth.is_lan(ip),
"authed": not auth.is_lan(ip) "authed": not auth.is_lan(ip)
and bool( and bool(
@@ -140,8 +141,8 @@ async def list_notes(request: Request, _: None = Depends(require_access)):
@app.get("/api/notes/recent") @app.get("/api/notes/recent")
async def recent(_: None = Depends(require_access)): async def recent(request: Request, _: None = Depends(require_access)):
return await notes.list_recent() return await notes.list_recent(current_user(request))
@app.get("/api/notes/{note_id}") @app.get("/api/notes/{note_id}")

View File

@@ -243,15 +243,15 @@ async def list_notes(username: str, limit: int = 100) -> list[dict]:
return notes 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: async with aiosqlite.connect(settings.database_path) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
rows = await db.execute( rows = await db.execute(
"SELECT n.id, n.title, n.updated_at, n.created_at, u.name AS username" "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" " 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 ?", " ORDER BY n.updated_at DESC LIMIT ?",
(limit,), (username, settings.common_space, limit),
) )
notes = [dict(r) for r in await rows.fetchall()] notes = [dict(r) for r in await rows.fetchall()]
for note in notes: for note in notes:

View File

@@ -273,6 +273,23 @@ body {
.note-meta { color: var(--muted); font-size: 12px; margin-top: 2px; } .note-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.note-tags { color: var(--primary); font-size: 12px; margin-top: 4px; } .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-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-chip { .tag-chip {
background: color-mix(in srgb, var(--primary) 12%, transparent); background: color-mix(in srgb, var(--primary) 12%, transparent);

View File

@@ -6,7 +6,7 @@ const root = document.getElementById("root");
const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" }); const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
marked.setOptions({ gfm: true, breaks: false }); 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 = [ const THEMES = [
{ id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6", "#8b5cf6"] }, { id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6", "#8b5cf6"] },
@@ -157,6 +157,7 @@ async function boot() {
try { try {
const me = await api("GET", "/api/me"); const me = await api("GET", "/api/me");
state.user = me.user; state.local = me.local; state.authed = me.authed; state.user = me.user; state.local = me.local; state.authed = me.authed;
state.commonSpace = me.common_space || "common";
} catch { return; } } catch { return; }
if (!state.local && !state.authed) { showLogin(); return; } if (!state.local && !state.authed) { showLogin(); return; }
showHome(); showHome();
@@ -230,7 +231,7 @@ function showHelp() {
el("p", { html: 'Use the toolbar to format text with <b>bold</b>, <i>italic</i>, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }), el("p", { html: 'Use the toolbar to format text with <b>bold</b>, <i>italic</i>, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }),
el("h2", { text: "Notes & sharing" }), el("h2", { text: "Notes & sharing" }),
el("p", { html: 'Notes are plain Markdown files. A note lives in a <i>space</i> (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 <i>space</i> (a user), chosen with the dropdown at the top of the editor. Tick the <b>common</b> checkbox to save it to the shared space everyone can see. Changing the space moves the note to that user.' }),
el("p", { html: 'Use <b>Attach</b> to upload a file and insert a link to it. <b>History</b> shows every change, and <b>Delete</b> soft-deletes a note.' }), el("p", { html: 'Use <b>Attach</b> to upload a file and insert a link to it. <b>History</b> shows every change, and <b>Delete</b> soft-deletes a note.' }),
]), ]),
]); ]);
@@ -366,6 +367,7 @@ async function populateUsers() {
const names = users.map((u) => u.name); const names = users.map((u) => u.name);
state.userNames = names; state.userNames = names;
for (const n of names) { for (const n of names) {
if (n === state.commonSpace) continue;
sel.appendChild(el("option", { value: n, text: n })); sel.appendChild(el("option", { value: n, text: n }));
} }
sel.value = state.user; sel.value = state.user;
@@ -402,9 +404,13 @@ function noteList(notes) {
const ul = el("ul", { class: "note-list" }); const ul = el("ul", { class: "note-list" });
for (const n of notes) { for (const n of notes) {
const tags = (n.tags || []).map((t) => `#${t}`).join(" "); 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-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 }), 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 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 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; } }); 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 })); spaceSel.appendChild(el("option", { value: n, text: n }));
} }
const initialSpace = owner || state.space || state.user; state.isCommon = owner === state.commonSpace;
spaceSel.value = initialSpace; state.space = owner && owner !== state.commonSpace ? owner : (state.user || realUsers[0]);
state.space = initialSpace; 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" }, [ const toolbar = el("div", { class: "toolbar" }, [
toolbarBtn("B", "bold"), toolbarBtn("I", "italic"), 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 attach = el("button", { class: "btn", text: "Attach", onclick: () => fileInput.click() });
const wrap = el("div", { class: "editor-wrap" }, [ 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, toolbar,
editorArea, editorArea,
hint, hint,
@@ -538,13 +552,14 @@ function runToolbar(action) {
async function persistNote() { async function persistNote() {
const html = state.editor.getHTML(); const html = state.editor.getHTML();
const body = turndownService.turndown(html); const body = turndownService.turndown(html);
const username = state.isCommon ? state.commonSpace : state.space;
const status = document.querySelector(".status"); const status = document.querySelector(".status");
try { try {
let note; let note;
if (state.noteId) { 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 { } else {
note = await api("POST", "/api/notes", { body, username: state.space }); note = await api("POST", "/api/notes", { body, username });
state.noteId = note.id; state.noteId = note.id;
} }
state.editor.commands.setContent(marked.parse(note.body)); state.editor.commands.setContent(marked.parse(note.body));
@@ -574,7 +589,7 @@ async function uploadAttachment(input) {
if (!file) return; if (!file) return;
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
fd.append("username", state.space); fd.append("username", state.isCommon ? state.commonSpace : state.space);
const status = document.querySelector(".status"); const status = document.querySelector(".status");
try { try {
const resp = await fetch("/api/attachments", { method: "POST", body: fd }); const resp = await fetch("/api/attachments", { method: "POST", body: fd });