diff --git a/backend/notes.py b/backend/notes.py index dca5997..df864ad 100644 --- a/backend/notes.py +++ b/backend/notes.py @@ -11,6 +11,9 @@ from backend.database import get_user_id TAG_RE = re.compile(r"#[A-Za-z0-9_-]+") +ATTACH_RE = re.compile(r"/api/attachments/([^/\s]+)/([A-Za-z0-9._-]+)") +IMAGE_EXT_RE = re.compile(r"\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|heic)$", re.IGNORECASE) + def extract_tags(text: str) -> list[str]: seen: list[str] = [] @@ -35,6 +38,16 @@ def extract_title(text: str) -> str: return "Untitled" +def note_attachments(body: str) -> tuple[int, str | None]: + count = 0 + preview = None + for m in ATTACH_RE.finditer(body): + count += 1 + if preview is None and IMAGE_EXT_RE.search(m.group(2)): + preview = f"/api/attachments/{m.group(1)}/{m.group(2)}" + return count, preview + + def note_path(username: str, note_id: str) -> str: return f"{username}/{note_id}.md" @@ -231,15 +244,15 @@ async def list_notes(username: str, limit: int = 100) -> 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" + "SELECT n.id, n.title, n.updated_at, n.created_at, n.path," + " u.name AS username" " FROM notes n JOIN users u ON u.id = n.user_id" " WHERE n.deleted_at IS NULL AND u.name = ?" " ORDER BY n.updated_at DESC LIMIT ?", (username, limit), ) notes = [dict(r) for r in await rows.fetchall()] - for note in notes: - note["tags"] = await get_note_tags(note["id"]) + await _attach_meta(notes) return notes @@ -247,15 +260,15 @@ 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" + "SELECT n.id, n.title, n.updated_at, n.created_at, n.path," + " u.name AS username" " FROM notes n JOIN users u ON u.id = n.user_id" " WHERE n.deleted_at IS NULL AND u.name IN (?, ?)" " ORDER BY n.updated_at DESC LIMIT ?", (username, settings.common_space, limit), ) notes = [dict(r) for r in await rows.fetchall()] - for note in notes: - note["tags"] = await get_note_tags(note["id"]) + await _attach_meta(notes) return notes @@ -270,6 +283,19 @@ async def get_note_tags(note_id: str) -> list[str]: return [r["name"] for r in await rows.fetchall()] +async def _attach_meta(notes: list[dict]) -> None: + for note in notes: + note["tags"] = await get_note_tags(note["id"]) + try: + body = await read_note_file(note["path"]) + count, preview = note_attachments(body) + except FileNotFoundError: + count, preview = 0, None + note["attachments"] = count + note["image"] = preview + note.pop("path", None) + + async def tag_counts() -> list[dict]: async with aiosqlite.connect(settings.database_path) as db: db.row_factory = aiosqlite.Row diff --git a/static/app.css b/static/app.css index c7f0ebe..46dd6f0 100644 --- a/static/app.css +++ b/static/app.css @@ -273,8 +273,20 @@ 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 { +.note-item { display: flex; align-items: center; gap: 12px; } +.note-body { flex: 1; min-width: 0; } +.note-thumb { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; +} +.attach-count { color: var(--muted); margin-left: 8px; } + +.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); diff --git a/static/app.js b/static/app.js index b32d000..6933cb0 100644 --- a/static/app.js +++ b/static/app.js @@ -410,10 +410,15 @@ function noteList(notes) { 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) }, [ + if (n.attachments) meta.appendChild(el("span", { class: "attach-count", text: n.attachments + " attachment" + (n.attachments === 1 ? "" : "s") })); + const bodyEl = el("div", { class: "note-body" }, [ el("div", { class: "note-title", text: n.title || "Untitled" }), meta, el("div", { class: "note-tags", text: tags }), + ]); + ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [ + n.image ? el("img", { class: "note-thumb", src: n.image, alt: "", loading: "lazy" }) : null, + bodyEl, ])); } return ul;