From ed6a7ed39c4cc14452992d6191341bdad9126605 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 15 May 2026 08:18:44 +0200 Subject: [PATCH] Ideas: add 'awaiting feedback' status with amber section + admin comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status cycles open → awaiting → done → reopen. Awaiting ideas float to the top in a 'Waiting for your feedback' section with an amber border (#f59e0b). Admin can attach an implementation note to any awaiting idea via POST /api/ideas/{id}/comment. The note appears inside the same card in a distinct sub-box with a subtle amber tint border, editable inline. The sub-box is visible to all users once a note exists. --- bincio/serve/models.py | 4 + bincio/serve/routers/ideas.py | 38 +++- site/src/components/IdeasPage.svelte | 292 +++++++++++++++++++-------- 3 files changed, 243 insertions(+), 91 deletions(-) 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} - -
- {#if editingId === idea.id} - - -
- - -
- {:else} -
-

- {#if done}{/if}{idea.title} -

-
- {#if isAdmin} - - {/if} - {#if isAdmin || idea.author === myHandle} - - - {/if} -
-
- {#if idea.body} -

{idea.body}

- {/if} -

- @{idea.author} · {relativeTime(idea.created_at)} -

+ {#if otherIdeas.length > 0} + + {/if} + {/if} + + {#snippet ideaContent(idea: Idea)} + {@const done = idea.status === 'done'} + {@const awaiting = idea.status === 'awaiting'} + +
+ +
+ + +
+ {#if editingId === idea.id} + + +
+ + +
+ {:else} +
+

+ {#if done}{/if}{idea.title} +

+
+ {#if isAdmin} + {@const nextLabel = done ? 'reopen' : awaiting ? 'done' : 'awaiting'} + {@const btnColor = done ? 'var(--text-5)' : awaiting ? 'var(--accent)' : '#f59e0b'} + {@const borderColor = done ? 'var(--border-sub)' : awaiting ? 'var(--accent)' : '#f59e0b'} + + {/if} + {#if isAdmin || idea.author === myHandle} + + {/if}
- - {/each} - - {/if} +
+ {#if idea.body} +

{idea.body}

+ {/if} +

+ @{idea.author} · {relativeTime(idea.created_at)} +

+ {/if} + + {#if idea.admin_comment || (isAdmin && awaiting)} +
+ {#if editingCommentId === idea.id} + +
+ + +
+ {:else} +
+
+ {#if idea.admin_comment} +

{idea.admin_comment}

+ {:else} +

Add an implementation note…

+ {/if} +
+ {#if isAdmin} + + {/if} +
+ {/if} +
+ {/if} +
+ {/snippet}