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,
+204 -88
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,97 +270,171 @@
{: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}
<ul class="space-y-3"> {#if awaitingIdeas.length > 0}
{#each ideas as idea (idea.id)} <div class="mb-2">
{@const done = idea.status === 'done'} <p class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: #f59e0b">
<li 🕐 Waiting for your feedback
class="rounded-xl border px-4 py-3 flex gap-3 transition-opacity" </p>
style="background: var(--bg-card); border-color: var(--border); opacity: {done ? '0.55' : '1'}" <ul class="space-y-3">
> {#each awaitingIdeas as idea (idea.id)}
<!-- Vote button --> <li
<div class="flex flex-col items-center shrink-0 pt-0.5"> class="rounded-xl border px-4 py-3 flex gap-3"
<button style="background: var(--bg-card); border-color: #f59e0b; border-width: 1.5px"
on:click={() => vote(idea)}
class="w-10 h-10 rounded-lg text-sm font-semibold flex flex-col items-center justify-center gap-0 leading-none transition-colors"
style={idea.my_vote
? 'background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent)'
: 'background: transparent; border: 1px solid var(--border-sub); color: var(--text-5)'}
title={idea.my_vote ? 'Remove vote' : '+1 this idea'}
> >
<span class="text-xs leading-none"></span> {@render ideaContent(idea)}
<span class="text-xs leading-none">{idea.vote_count}</span> </li>
</button> {/each}
</div> </ul>
</div>
{/if}
<!-- Content --> {#if otherIdeas.length > 0}
<div class="flex-1 min-w-0"> <ul class="space-y-3">
{#if editingId === idea.id} {#each otherIdeas as idea (idea.id)}
<input {@const done = idea.status === 'done'}
bind:value={editTitle} <li
class="w-full rounded-lg px-3 py-1.5 text-sm mb-2" class="rounded-xl border px-4 py-3 flex gap-3 transition-opacity"
style="background: var(--bg-elevated); border: 1px solid var(--border-sub); color: var(--text-primary)" style="background: var(--bg-card); border-color: var(--border); opacity: {done ? '0.55' : '1'}"
/> >
<textarea {@render ideaContent(idea)}
bind:value={editBody} </li>
rows="2" {/each}
class="w-full rounded-lg px-3 py-1.5 text-sm mb-2 resize-none" </ul>
style="background: var(--bg-elevated); border: 1px solid var(--border-sub); color: var(--text-primary)" {/if}
></textarea> {/if}
<div class="flex gap-2">
<button {#snippet ideaContent(idea: Idea)}
on:click={() => saveEdit(idea)} {@const done = idea.status === 'done'}
disabled={editSaving || !editTitle.trim()} {@const awaiting = idea.status === 'awaiting'}
class="px-3 py-1 rounded-lg text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50" <!-- Vote button -->
style="background: var(--accent)" <div class="flex flex-col items-center shrink-0 pt-0.5">
>{editSaving ? 'Saving…' : 'Save'}</button> <button
<button on:click={() => vote(idea)}
on:click={cancelEdit} class="w-10 h-10 rounded-lg text-sm font-semibold flex flex-col items-center justify-center gap-0 leading-none transition-colors"
class="px-3 py-1 rounded-lg text-xs transition-colors" style={idea.my_vote
style="color: var(--text-5)" ? 'background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent)'
>Cancel</button> : 'background: transparent; border: 1px solid var(--border-sub); color: var(--text-5)'}
</div> title={idea.my_vote ? 'Remove vote' : '+1 this idea'}
{:else} >
<div class="flex items-start justify-between gap-2"> <span class="text-xs leading-none"></span>
<p class="font-medium text-sm" style="color: var(--text-primary)"> <span class="text-xs leading-none">{idea.vote_count}</span>
{#if done}<span class="mr-1.5 text-green-500"></span>{/if}{idea.title} </button>
</p> </div>
<div class="flex items-center gap-1 shrink-0">
{#if isAdmin} <!-- Content -->
<button <div class="flex-1 min-w-0">
on:click={() => toggleStatus(idea)} {#if editingId === idea.id}
class="text-xs px-1.5 py-0.5 rounded transition-colors" <input
style="color: {done ? 'var(--text-5)' : 'var(--accent)'}; border: 1px solid {done ? 'var(--border-sub)' : 'var(--accent)'}" bind:value={editTitle}
title={done ? 'Mark as open' : 'Mark as done'} class="w-full rounded-lg px-3 py-1.5 text-sm mb-2"
>{done ? 'reopen' : 'done'}</button> style="background: var(--bg-elevated); border: 1px solid var(--border-sub); color: var(--text-primary)"
{/if} />
{#if isAdmin || idea.author === myHandle} <textarea
<button bind:value={editBody}
on:click={() => startEdit(idea)} rows="2"
class="text-xs px-1.5 py-0.5 rounded transition-colors hover:text-white" class="w-full rounded-lg px-3 py-1.5 text-sm mb-2 resize-none"
style="color: var(--text-5)" style="background: var(--bg-elevated); border: 1px solid var(--border-sub); color: var(--text-primary)"
title="Edit" ></textarea>
></button> <div class="flex gap-2">
<button <button
on:click={() => deleteIdea(idea)} on:click={() => saveEdit(idea)}
class="text-xs px-1.5 py-0.5 rounded transition-colors hover:text-red-400" disabled={editSaving || !editTitle.trim()}
style="color: var(--text-5)" class="px-3 py-1 rounded-lg text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
title="Delete" style="background: var(--accent)"
>×</button> >{editSaving ? 'Saving…' : 'Save'}</button>
{/if} <button
</div> on:click={cancelEdit}
</div> class="px-3 py-1 rounded-lg text-xs transition-colors"
{#if idea.body} style="color: var(--text-5)"
<p class="text-xs mt-1" style="color: var(--text-4)">{idea.body}</p> >Cancel</button>
{/if} </div>
<p class="text-xs mt-1.5" style="color: var(--text-5)"> {:else}
@{idea.author} · {relativeTime(idea.created_at)} <div class="flex items-start justify-between gap-2">
</p> <p class="font-medium text-sm" style="color: var(--text-primary)">
{#if done}<span class="mr-1.5 text-green-500"></span>{/if}{idea.title}
</p>
<div class="flex items-center gap-1 shrink-0">
{#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
on:click={() => toggleStatus(idea)}
class="text-xs px-1.5 py-0.5 rounded transition-colors"
style="color: {btnColor}; border: 1px solid {borderColor}"
title="Cycle status"
>{nextLabel}</button>
{/if}
{#if isAdmin || idea.author === myHandle}
<button
on:click={() => startEdit(idea)}
class="text-xs px-1.5 py-0.5 rounded transition-colors hover:text-white"
style="color: var(--text-5)"
title="Edit"
></button>
<button
on:click={() => deleteIdea(idea)}
class="text-xs px-1.5 py-0.5 rounded transition-colors hover:text-red-400"
style="color: var(--text-5)"
title="Delete"
>×</button>
{/if} {/if}
</div> </div>
</li> </div>
{/each} {#if idea.body}
</ul> <p class="text-xs mt-1" style="color: var(--text-4)">{idea.body}</p>
{/if} {/if}
<p class="text-xs mt-1.5" style="color: var(--text-5)">
@{idea.author} · {relativeTime(idea.created_at)}
</p>
{/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>
{:else}
<div class="flex items-start justify-between gap-2">
<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}
</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>