feat: feature requests board with voting

- [FEATURES] tab next to PINNED in channel header
- Create, vote/unvote, filter by status (open/planned/done/rejected)
- Sort by vote count (most voted first)
- Status selector for moderation
- DB: feature_requests + feature_request_votes tables
- Backend: full CRUD + vote endpoints under /servers/{id}/feature-requests
This commit is contained in:
2026-07-02 13:11:52 -04:00
parent 3002626ac4
commit 5ca5e018ca
7 changed files with 525 additions and 1 deletions
+22
View File
@@ -18,6 +18,7 @@ import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
import { useContextMenu } from "./ContextMenu.tsx";
import { PinnedMessages } from "./PinnedMessages.tsx";
import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
import { ReactionBar } from "./ReactionBar.tsx";
import { EmojiPicker } from "./EmojiPicker.tsx";
import { ReplyBar } from "./ReplyBar.tsx";
@@ -308,6 +309,7 @@ export function ChatArea() {
const markRead = useReadStatesStore((s) => s.markRead);
const [showPinned, setShowPinned] = useState(false);
const [showFeatureRequests, setShowFeatureRequests] = useState(false);
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
const [replyToMessage, setReplyToMessage] = useState<any | null>(null);
const pinMessage = useMessageStore((s) => s.pinMessage);
@@ -662,6 +664,7 @@ export function ChatArea() {
<button
onClick={() => {
setShowPinned(false);
setShowFeatureRequests(false);
setShowSearch((prev) => !prev);
}}
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
@@ -672,6 +675,7 @@ export function ChatArea() {
<button
onClick={() => {
setShowSearch(false);
setShowFeatureRequests(false);
setShowPinned((prev) => !prev);
}}
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
@@ -679,6 +683,17 @@ export function ChatArea() {
>
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
</button>
<button
onClick={() => {
setShowSearch(false);
setShowPinned(false);
setShowFeatureRequests((prev) => !prev);
}}
className={`text-xs font-mono ${showFeatureRequests ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Feature requests"
>
[FEATURES]
</button>
</div>
)}
</div>
@@ -739,6 +754,13 @@ export function ChatArea() {
onJump={handleJumpToMessage}
/>
)}
{showFeatureRequests && activeServerId && (
<FeatureRequestsPanel
serverId={activeServerId}
channelId={activeChannelId || undefined}
onClose={() => setShowFeatureRequests(false)}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { useFeatureRequestStore, type FeatureRequest } from "../stores/featureRequest.ts";
import { useAuthStore } from "../stores/auth.ts";
interface FeatureRequestsPanelProps {
serverId: string;
channelId?: string;
onClose: () => void;
}
const STATUS_LABELS: Record<string, string> = {
open: "OPEN",
planned: "PLANNED",
done: "DONE",
rejected: "REJECTED",
};
const STATUS_COLORS: Record<string, string> = {
open: "text-gb-green",
planned: "text-gb-yellow",
done: "text-gb-aqua",
rejected: "text-gb-red",
};
export function FeatureRequestsPanel({ serverId, channelId, onClose }: FeatureRequestsPanelProps) {
const requests = useFeatureRequestStore((s) => s.requestsByServer[serverId] || []);
const fetchRequests = useFeatureRequestStore((s) => s.fetchRequests);
const createRequest = useFeatureRequestStore((s) => s.createRequest);
const vote = useFeatureRequestStore((s) => s.vote);
const unvote = useFeatureRequestStore((s) => s.unvote);
const updateStatus = useFeatureRequestStore((s) => s.updateStatus);
const currentUser = useAuthStore((s) => s.user);
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [filter, setFilter] = useState<string>("all");
useEffect(() => {
fetchRequests(serverId);
}, [serverId, fetchRequests]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleCreate = async () => {
if (!title.trim()) return;
setSubmitting(true);
try {
await createRequest(serverId, title.trim(), description.trim(), channelId);
setTitle("");
setDescription("");
setShowCreate(false);
} catch {
// silent
} finally {
setSubmitting(false);
}
};
const handleVote = async (fr: FeatureRequest) => {
const hasVoted = currentUser ? fr.voters.includes(currentUser.id) : false;
if (hasVoted) {
await unvote(serverId, fr.id);
} else {
await vote(serverId, fr.id);
}
};
const filtered = filter === "all" ? requests : requests.filter((r) => r.status === filter);
return (
<div className="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">FEATURE REQUESTS ({requests.length})</span>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate((p) => !p)}
className="text-xs text-gb-aqua hover:text-gb-orange font-mono"
>
[+NEW]
</button>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
</div>
{/* Filter tabs */}
<div className="px-3 py-1 border-b border-gb-bg-t flex gap-3 text-[10px] font-mono">
{["all", "open", "planned", "done", "rejected"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`${filter === s ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}
>
{s.toUpperCase()}
</button>
))}
</div>
{/* Create form */}
{showCreate && (
<div className="px-3 py-2 border-b border-gb-bg-t">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Feature title..."
className="w-full bg-gb-bg-s border border-gb-bg-t text-gb-fg text-xs font-mono px-2 py-1 mb-1 outline-none focus:border-gb-orange"
maxLength={255}
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)..."
className="w-full bg-gb-bg-s border border-gb-bg-t text-gb-fg text-xs font-mono px-2 py-1 mb-1 outline-none focus:border-gb-orange resize-none h-16"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={submitting || !title.trim()}
className="text-xs text-gb-green hover:text-gb-orange font-mono disabled:opacity-40"
>
[SUBMIT]
</button>
<button
onClick={() => { setShowCreate(false); setTitle(""); setDescription(""); }}
className="text-xs text-gb-fg-f hover:text-gb-red font-mono"
>
[CANCEL]
</button>
</div>
</div>
)}
{/* List */}
<div className="max-h-80 overflow-y-auto font-mono text-xs">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-gb-fg-f">
{requests.length === 0 ? "[no feature requests yet]" : "[none matching filter]"}
</div>
) : (
filtered.map((fr) => {
const hasVoted = currentUser ? fr.voters.includes(currentUser.id) : false;
return (
<div
key={fr.id}
className="w-full px-3 py-2 border-b border-gb-bg-t last:border-b-0 hover:bg-gb-bg-s/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<button
onClick={() => handleVote(fr)}
className={`shrink-0 px-1 border text-[10px] ${
hasVoted
? "border-gb-orange text-gb-orange bg-gb-orange/10"
: "border-gb-bg-t text-gb-fg-f hover:border-gb-orange hover:text-gb-orange"
}`}
title={hasVoted ? "Remove vote" : "Vote"}
>
{fr.vote_count}
</button>
<span className="text-gb-fg font-bold truncate">{fr.title}</span>
<span className={`text-[10px] ${STATUS_COLORS[fr.status]}`}>
[{STATUS_LABELS[fr.status]}]
</span>
</div>
{fr.description && (
<div className="text-gb-fg-f mt-1 pl-8 text-[11px] break-words whitespace-pre-wrap">
{fr.description}
</div>
)}
<div className="text-[10px] text-gb-fg-f mt-1 pl-8">
{new Date(fr.created_at).toLocaleDateString()}
</div>
</div>
{/* Status selector for admins - simple cycle */}
<select
value={fr.status}
onChange={(e) => updateStatus(serverId, fr.id, e.target.value)}
className="bg-gb-bg-s border border-gb-bg-t text-gb-fg-f text-[10px] font-mono px-1 py-0.5 cursor-pointer"
>
<option value="open">Open</option>
<option value="planned">Planned</option>
<option value="done">Done</option>
<option value="rejected">Rejected</option>
</select>
</div>
</div>
);
})
)}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { create } from "zustand";
import api from "../lib/api.ts";
export interface FeatureRequest {
id: string;
server_id: string;
channel_id: string;
author_id: string;
title: string;
description: string;
status: "open" | "planned" | "done" | "rejected";
vote_count: number;
voters: string[];
created_at: string;
updated_at: string;
}
interface FeatureRequestStore {
requestsByServer: Record<string, FeatureRequest[]>;
fetchRequests: (serverId: string) => Promise<void>;
createRequest: (serverId: string, title: string, description: string, channelId?: string) => Promise<void>;
vote: (serverId: string, requestId: string) => Promise<void>;
unvote: (serverId: string, requestId: string) => Promise<void>;
updateStatus: (serverId: string, requestId: string, status: string) => Promise<void>;
}
export const useFeatureRequestStore = create<FeatureRequestStore>((set, get) => ({
requestsByServer: {},
fetchRequests: async (serverId: string) => {
const data = await api.get<FeatureRequest[]>(`/servers/${serverId}/feature-requests`);
set((s) => ({
requestsByServer: { ...s.requestsByServer, [serverId]: data },
}));
},
createRequest: async (serverId, title, description, channelId) => {
await api.post(`/servers/${serverId}/feature-requests`, { title, description, channel_id: channelId });
await get().fetchRequests(serverId);
},
vote: async (serverId, requestId) => {
await api.post(`/servers/${serverId}/feature-requests/${requestId}/vote`);
await get().fetchRequests(serverId);
},
unvote: async (serverId, requestId) => {
await api.delete(`/servers/${serverId}/feature-requests/${requestId}/vote`);
await get().fetchRequests(serverId);
},
updateStatus: async (serverId, requestId, status) => {
await api.patch(`/servers/${serverId}/feature-requests/${requestId}/status`, { status });
await get().fetchRequests(serverId);
},
}));