Ideas: add 'awaiting feedback' status with amber section + admin comment

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.
This commit is contained in:
Davide Scaini
2026-05-15 08:18:44 +02:00
parent 3b675a68b0
commit ed6a7ed39c
3 changed files with 243 additions and 91 deletions
+4
View File
@@ -86,3 +86,7 @@ class CreateInviteRequest(BaseModel):
class IdeaBody(BaseModel): class IdeaBody(BaseModel):
title: str title: str
body: str = "" body: str = ""
class IdeaCommentBody(BaseModel):
comment: str = ""
+35 -3
View File
@@ -12,7 +12,7 @@ from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from bincio.serve import deps from bincio.serve import deps
from bincio.serve.models import IdeaBody from bincio.serve.models import IdeaBody, IdeaCommentBody
router = APIRouter() router = APIRouter()
@@ -43,7 +43,11 @@ async def list_ideas(
idea["vote_count"] = len(votes) idea["vote_count"] = len(votes)
idea["my_vote"] = user.handle in votes idea["my_vote"] = user.handle in votes
ideas.append(idea) 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}) return JSONResponse({"ideas": ideas})
@@ -114,13 +118,41 @@ async def toggle_idea_status(
with open(path, "r+", encoding="utf-8") as f: with open(path, "r+", encoding="utf-8") as f:
_fcntl.flock(f, _fcntl.LOCK_EX) _fcntl.flock(f, _fcntl.LOCK_EX)
idea = json.load(f) 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.seek(0)
f.truncate() f.truncate()
json.dump(idea, f, ensure_ascii=False, indent=2) json.dump(idea, f, ensure_ascii=False, indent=2)
return JSONResponse({"status": idea["status"]}) 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}") @router.patch("/api/ideas/{idea_id}")
async def edit_idea( async def edit_idea(
idea_id: str, idea_id: str,
+124 -8
View File
@@ -11,6 +11,7 @@
vote_count: number; vote_count: number;
my_vote: boolean; my_vote: boolean;
status?: string; status?: string;
admin_comment?: string | null;
} }
let ideas: Idea[] = []; let ideas: Idea[] = [];
@@ -29,6 +30,40 @@
let editBody = ''; let editBody = '';
let editSaving = false; 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) { function startEdit(idea: Idea) {
editingId = idea.id; editingId = idea.id;
editTitle = idea.title; 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) { async function toggleStatus(idea: Idea) {
const r = await fetch(`/api/ideas/${idea.id}/status`, { const r = await fetch(`/api/ideas/${idea.id}/status`, {
method: 'POST', method: 'POST',
@@ -154,13 +193,16 @@
const d = await r.json(); const d = await r.json();
idea.status = d.status; idea.status = d.status;
ideas = [...ideas].sort((a, b) => 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.vote_count - a.vote_count ||
b.created_at - a.created_at 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) { async function deleteIdea(idea: Idea) {
if (!confirm(`Delete "${idea.title}"?`)) return; if (!confirm(`Delete "${idea.title}"?`)) return;
const r = await fetch(`/api/ideas/${idea.id}`, { const r = await fetch(`/api/ideas/${idea.id}`, {
@@ -228,13 +270,42 @@
{:else if ideas.length === 0} {:else if ideas.length === 0}
<p class="text-sm" style="color: var(--text-5)">No ideas yet. Be the first!</p> <p class="text-sm" style="color: var(--text-5)">No ideas yet. Be the first!</p>
{:else} {:else}
{#if awaitingIdeas.length > 0}
<div class="mb-2">
<p class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: #f59e0b">
🕐 Waiting for your feedback
</p>
<ul class="space-y-3"> <ul class="space-y-3">
{#each ideas as idea (idea.id)} {#each awaitingIdeas as idea (idea.id)}
<li
class="rounded-xl border px-4 py-3 flex gap-3"
style="background: var(--bg-card); border-color: #f59e0b; border-width: 1.5px"
>
{@render ideaContent(idea)}
</li>
{/each}
</ul>
</div>
{/if}
{#if otherIdeas.length > 0}
<ul class="space-y-3">
{#each otherIdeas as idea (idea.id)}
{@const done = idea.status === 'done'} {@const done = idea.status === 'done'}
<li <li
class="rounded-xl border px-4 py-3 flex gap-3 transition-opacity" class="rounded-xl border px-4 py-3 flex gap-3 transition-opacity"
style="background: var(--bg-card); border-color: var(--border); opacity: {done ? '0.55' : '1'}" style="background: var(--bg-card); border-color: var(--border); opacity: {done ? '0.55' : '1'}"
> >
{@render ideaContent(idea)}
</li>
{/each}
</ul>
{/if}
{/if}
{#snippet ideaContent(idea: Idea)}
{@const done = idea.status === 'done'}
{@const awaiting = idea.status === 'awaiting'}
<!-- Vote button --> <!-- Vote button -->
<div class="flex flex-col items-center shrink-0 pt-0.5"> <div class="flex flex-col items-center shrink-0 pt-0.5">
<button <button
@@ -284,12 +355,15 @@
</p> </p>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
{#if isAdmin} {#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'}
<button <button
on:click={() => toggleStatus(idea)} on:click={() => toggleStatus(idea)}
class="text-xs px-1.5 py-0.5 rounded transition-colors" class="text-xs px-1.5 py-0.5 rounded transition-colors"
style="color: {done ? 'var(--text-5)' : 'var(--accent)'}; border: 1px solid {done ? 'var(--border-sub)' : 'var(--accent)'}" style="color: {btnColor}; border: 1px solid {borderColor}"
title={done ? 'Mark as open' : 'Mark as done'} title="Cycle status"
>{done ? 'reopen' : 'done'}</button> >{nextLabel}</button>
{/if} {/if}
{#if isAdmin || idea.author === myHandle} {#if isAdmin || idea.author === myHandle}
<button <button
@@ -314,11 +388,53 @@
@{idea.author} · {relativeTime(idea.created_at)} @{idea.author} · {relativeTime(idea.created_at)}
</p> </p>
{/if} {/if}
{#if idea.admin_comment || (isAdmin && awaiting)}
<div class="mt-3 rounded-lg px-3 py-2.5" style="background: var(--bg-elevated); border: 1px solid rgba(245,158,11,0.25)">
{#if editingCommentId === idea.id}
<textarea
bind:value={commentDraft}
rows="3"
placeholder="Add an implementation note…"
class="w-full rounded px-2 py-1.5 text-xs resize-none mb-2"
style="background: var(--bg-card); border: 1px solid var(--border-sub); color: var(--text-primary)"
></textarea>
<div class="flex gap-2">
<button
on:click={() => saveComment(idea)}
disabled={commentSaving}
class="px-3 py-1 rounded-lg text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
style="background: var(--accent)"
>{commentSaving ? 'Saving…' : 'Save'}</button>
<button
on:click={cancelEditComment}
class="px-3 py-1 rounded-lg text-xs transition-colors"
style="color: var(--text-5)"
>Cancel</button>
</div> </div>
</li> {:else}
{/each} <div class="flex items-start justify-between gap-2">
</ul> <div class="flex-1 min-w-0">
{#if idea.admin_comment}
<p class="text-xs leading-relaxed" style="color: var(--text-4)">{idea.admin_comment}</p>
{:else}
<p class="text-xs italic" style="color: var(--text-5)">Add an implementation note…</p>
{/if} {/if}
</div>
{#if isAdmin}
<button
on:click={() => startEditComment(idea)}
class="text-xs shrink-0 transition-colors hover:text-white"
style="color: var(--text-5)"
title="Edit note"
></button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
<div class="mt-10 pt-6 border-t text-sm" style="border-color: var(--border); color: var(--text-5)"> <div class="mt-10 pt-6 border-t text-sm" style="border-color: var(--border); color: var(--text-5)">
<a href="/feedback/" style="color: var(--accent)" class="hover:underline">Found a bug? Have feedback to share? Click here.</a> <a href="/feedback/" style="color: var(--accent)" class="hover:underline">Found a bug? Have feedback to share? Click here.</a>