diff --git a/bincio/serve/models.py b/bincio/serve/models.py index fb2945d..1d6ea13 100644 --- a/bincio/serve/models.py +++ b/bincio/serve/models.py @@ -86,3 +86,7 @@ class CreateInviteRequest(BaseModel): class IdeaBody(BaseModel): title: str body: str = "" + + +class IdeaCommentBody(BaseModel): + comment: str = "" diff --git a/bincio/serve/routers/ideas.py b/bincio/serve/routers/ideas.py index 7d73477..d9e4d8c 100644 --- a/bincio/serve/routers/ideas.py +++ b/bincio/serve/routers/ideas.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile from fastapi.responses import JSONResponse from bincio.serve import deps -from bincio.serve.models import IdeaBody +from bincio.serve.models import IdeaBody, IdeaCommentBody router = APIRouter() @@ -43,7 +43,11 @@ async def list_ideas( idea["vote_count"] = len(votes) idea["my_vote"] = user.handle in votes ideas.append(idea) - ideas.sort(key=lambda x: (x.get("status") == "done", -x["vote_count"], -x["created_at"])) + def _sort_key(x: dict): + s = x.get("status") or "open" + order = {"awaiting": 0, "open": 1, "done": 2} + return (order.get(s, 1), -x["vote_count"], -x["created_at"]) + ideas.sort(key=_sort_key) return JSONResponse({"ideas": ideas}) @@ -114,13 +118,41 @@ async def toggle_idea_status( with open(path, "r+", encoding="utf-8") as f: _fcntl.flock(f, _fcntl.LOCK_EX) idea = json.load(f) - idea["status"] = "open" if idea.get("status") == "done" else "done" + cycle = {"open": "awaiting", "awaiting": "done", "done": "open"} + idea["status"] = cycle.get(idea.get("status") or "open", "awaiting") f.seek(0) f.truncate() json.dump(idea, f, ensure_ascii=False, indent=2) return JSONResponse({"status": idea["status"]}) +@router.post("/api/ideas/{idea_id}/comment") +async def set_idea_comment( + idea_id: str, + data: IdeaCommentBody, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = deps._require_user(bincio_session) + if not user.is_admin: + raise HTTPException(403, "Forbidden") + dd = deps._get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + comment = data.comment.strip()[:1000] + with open(path, "r+", encoding="utf-8") as f: + _fcntl.flock(f, _fcntl.LOCK_EX) + idea = json.load(f) + if comment: + idea["admin_comment"] = comment + else: + idea.pop("admin_comment", None) + f.seek(0) + f.truncate() + json.dump(idea, f, ensure_ascii=False, indent=2) + return JSONResponse({"ok": True, "admin_comment": comment or None}) + + @router.patch("/api/ideas/{idea_id}") async def edit_idea( idea_id: str, diff --git a/site/src/components/IdeasPage.svelte b/site/src/components/IdeasPage.svelte index 0a5ae2f..9711f1f 100644 --- a/site/src/components/IdeasPage.svelte +++ b/site/src/components/IdeasPage.svelte @@ -11,6 +11,7 @@ vote_count: number; my_vote: boolean; status?: string; + admin_comment?: string | null; } let ideas: Idea[] = []; @@ -29,6 +30,40 @@ let editBody = ''; let editSaving = false; + let editingCommentId: string | null = null; + let commentDraft = ''; + let commentSaving = false; + + function startEditComment(idea: Idea) { + editingCommentId = idea.id; + commentDraft = idea.admin_comment ?? ''; + } + + function cancelEditComment() { + editingCommentId = null; + commentDraft = ''; + } + + async function saveComment(idea: Idea) { + commentSaving = true; + try { + const r = await fetch(`/api/ideas/${idea.id}/comment`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: commentDraft.trim() }), + }); + if (r.ok) { + idea.admin_comment = commentDraft.trim() || null; + ideas = ideas; + editingCommentId = null; + commentDraft = ''; + } + } finally { + commentSaving = false; + } + } + function startEdit(idea: Idea) { editingId = idea.id; editTitle = idea.title; @@ -145,6 +180,10 @@ } } + function statusOrder(s: string | undefined) { + return s === 'awaiting' ? 0 : s === 'done' ? 2 : 1; + } + async function toggleStatus(idea: Idea) { const r = await fetch(`/api/ideas/${idea.id}/status`, { method: 'POST', @@ -154,13 +193,16 @@ const d = await r.json(); idea.status = d.status; ideas = [...ideas].sort((a, b) => - (a.status === 'done' ? 1 : 0) - (b.status === 'done' ? 1 : 0) || + statusOrder(a.status) - statusOrder(b.status) || b.vote_count - a.vote_count || b.created_at - a.created_at ); } } + $: awaitingIdeas = ideas.filter(i => i.status === 'awaiting'); + $: otherIdeas = ideas.filter(i => i.status !== 'awaiting'); + async function deleteIdea(idea: Idea) { if (!confirm(`Delete "${idea.title}"?`)) return; const r = await fetch(`/api/ideas/${idea.id}`, { @@ -228,97 +270,171 @@ {:else if ideas.length === 0}
No ideas yet. Be the first!
{:else} -- {#if done}✓{/if}{idea.title} -
-{idea.body}
- {/if} -- @{idea.author} · {relativeTime(idea.created_at)} -
+ {#if otherIdeas.length > 0} ++ {#if done}✓{/if}{idea.title} +
+{idea.body}
+ {/if} ++ @{idea.author} · {relativeTime(idea.created_at)} +
+ {/if} + + {#if idea.admin_comment || (isAdmin && awaiting)} +{idea.admin_comment}
+ {:else} +Add an implementation note…
+ {/if} +