ideas: add done/reopen status toggle for admins
Admin-only POST /api/ideas/{id}/status toggles status between open and
done. Done ideas are greyed out (opacity 0.55), show a green checkmark,
and sink to the bottom of the list. Admins see done/reopen buttons on
each card.
This commit is contained in:
+23
-1
@@ -2750,7 +2750,7 @@ 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["vote_count"], -x["created_at"]))
|
ideas.sort(key=lambda x: (x.get("status") == "done", -x["vote_count"], -x["created_at"]))
|
||||||
return JSONResponse({"ideas": ideas})
|
return JSONResponse({"ideas": ideas})
|
||||||
|
|
||||||
|
|
||||||
@@ -2806,6 +2806,28 @@ async def toggle_idea_vote(
|
|||||||
return JSONResponse({"voted": voted, "votes": len(votes)})
|
return JSONResponse({"voted": voted, "votes": len(votes)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ideas/{idea_id}/status")
|
||||||
|
async def toggle_idea_status(
|
||||||
|
idea_id: str,
|
||||||
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
|
) -> JSONResponse:
|
||||||
|
user = _require_user(bincio_session)
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(403, "Forbidden")
|
||||||
|
dd = _get_data_dir()
|
||||||
|
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
|
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"
|
||||||
|
f.seek(0)
|
||||||
|
f.truncate()
|
||||||
|
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||||
|
return JSONResponse({"status": idea["status"]})
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/ideas/{idea_id}")
|
@app.delete("/api/ideas/{idea_id}")
|
||||||
async def delete_idea(
|
async def delete_idea(
|
||||||
idea_id: str,
|
idea_id: str,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
votes: string[];
|
votes: string[];
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
my_vote: boolean;
|
my_vote: boolean;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ideas: Idea[] = [];
|
let ideas: Idea[] = [];
|
||||||
@@ -108,6 +109,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(idea: Idea) {
|
||||||
|
const r = await fetch(`/api/ideas/${idea.id}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
idea.status = d.status;
|
||||||
|
ideas = [...ideas].sort((a, b) =>
|
||||||
|
(a.status === 'done' ? 1 : 0) - (b.status === 'done' ? 1 : 0) ||
|
||||||
|
b.vote_count - a.vote_count ||
|
||||||
|
b.created_at - a.created_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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}`, {
|
||||||
@@ -170,7 +187,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{#each ideas as idea (idea.id)}
|
{#each ideas as idea (idea.id)}
|
||||||
<li class="rounded-xl border px-4 py-3 flex gap-3" style="background: var(--bg-card); border-color: var(--border)">
|
{@const done = idea.status === 'done'}
|
||||||
|
<li
|
||||||
|
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'}"
|
||||||
|
>
|
||||||
<!-- 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
|
||||||
@@ -189,15 +210,27 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<p class="font-medium text-sm" style="color: var(--text-primary)">{idea.title}</p>
|
<p class="font-medium text-sm" style="color: var(--text-primary)">
|
||||||
{#if isAdmin || idea.author === myHandle}
|
{#if done}<span class="mr-1.5 text-green-500">✓</span>{/if}{idea.title}
|
||||||
<button
|
</p>
|
||||||
on:click={() => deleteIdea(idea)}
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
class="shrink-0 text-xs px-1.5 py-0.5 rounded transition-colors hover:text-red-400"
|
{#if isAdmin}
|
||||||
style="color: var(--text-5)"
|
<button
|
||||||
title="Delete"
|
on:click={() => toggleStatus(idea)}
|
||||||
>×</button>
|
class="text-xs px-1.5 py-0.5 rounded transition-colors"
|
||||||
{/if}
|
style="color: {done ? 'var(--text-5)' : 'var(--accent)'}; border: 1px solid {done ? 'var(--border-sub)' : 'var(--accent)'}"
|
||||||
|
title={done ? 'Mark as open' : 'Mark as done'}
|
||||||
|
>{done ? 'reopen' : 'done'}</button>
|
||||||
|
{/if}
|
||||||
|
{#if isAdmin || idea.author === myHandle}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if idea.body}
|
{#if idea.body}
|
||||||
<p class="text-xs mt-1" style="color: var(--text-4)">{idea.body}</p>
|
<p class="text-xs mt-1" style="color: var(--text-4)">{idea.body}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user