diff --git a/packages/client/src/components/inline-label-editor.css.ts b/packages/client/src/components/inline-label-editor.css.ts index cf24fc2..acb19be 100644 --- a/packages/client/src/components/inline-label-editor.css.ts +++ b/packages/client/src/components/inline-label-editor.css.ts @@ -3,53 +3,66 @@ import { vars } from "../theme.css.ts"; export const wrapper = style({ position: "relative", - display: "flex", - flexWrap: "wrap", - alignItems: "center", - gap: vars.space.sm, }); export const editButton = style({ display: "inline-flex", alignItems: "center", - justifyContent: "center", + gap: "4px", background: "none", border: "none", color: vars.color.textMuted, + fontSize: "0.8125rem", + fontFamily: vars.font.body, cursor: "pointer", - padding: vars.space.xs, - borderRadius: vars.radius.sm, - lineHeight: 1, - fontSize: "0.875rem", + padding: 0, transition: "color 0.15s", ":hover": { - color: vars.color.text, + color: vars.color.primary, }, }); +export const labels = style({ + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: vars.space.sm, +}); + export const dropdown = style({ position: "absolute", zIndex: 10, insetBlockStart: "100%", - insetInlineEnd: 0, - marginBlockStart: vars.space.xs, - minInlineSize: "200px", - maxBlockSize: "240px", + insetInlineStart: 0, + marginBlockStart: vars.space.sm, + minInlineSize: "220px", + maxBlockSize: "280px", overflowY: "auto", backgroundColor: vars.color.surface, border: `1px solid ${vars.color.border}`, borderRadius: vars.radius.sm, - boxShadow: `0 4px 12px ${vars.color.shadow}`, - paddingBlock: vars.space.xs, + boxShadow: `0 4px 16px ${vars.color.shadow}`, +}); + +export const dropdownHeader = style({ + paddingBlock: vars.space.sm, + paddingInline: vars.space.md, + fontSize: "0.75rem", + fontWeight: 600, + color: vars.color.textMuted, + textTransform: "uppercase", + letterSpacing: "0.04em", + borderBlockEnd: `1px solid ${vars.color.border}`, }); export const option = style({ display: "flex", alignItems: "center", gap: vars.space.sm, - paddingBlock: vars.space.xs, - paddingInline: vars.space.sm, + paddingBlock: vars.space.sm, + paddingInline: vars.space.md, cursor: "pointer", + fontSize: "0.875rem", transition: "background-color 0.1s", ":hover": { backgroundColor: vars.color.surfaceHover, diff --git a/packages/client/src/components/inline-label-editor.tsx b/packages/client/src/components/inline-label-editor.tsx index 2930a2e..563b83b 100644 --- a/packages/client/src/components/inline-label-editor.tsx +++ b/packages/client/src/components/inline-label-editor.tsx @@ -1,6 +1,5 @@ import { useSignal } from "@preact/signals"; import { useEffect, useRef } from "preact/hooks"; -import * as ui from "../ui.css.ts"; import * as s from "./inline-label-editor.css.ts"; import { LabelBadge } from "./label-badge.tsx"; import type { LabelOption } from "./label-picker.tsx"; @@ -10,10 +9,12 @@ export function InlineLabelEditor({ labels, selectedIds, onToggle, + disabled = false, }: { labels: LabelOption[]; selectedIds: string[]; onToggle: (labelId: string, selected: boolean) => void; + disabled?: boolean; }) { const open = useSignal(false); const wrapperRef = useRef(null); @@ -35,27 +36,28 @@ export function InlineLabelEditor({ return (
- Labels: - {selectedLabels.length > 0 ? ( - - {selectedLabels.map((l) => ( - - ))} - - ) : ( - None - )} - +
+ + {selectedLabels.map((l) => ( + + ))} +
{open.value && (
+
Apply labels
{labels.map((label) => { const isSelected = selectedIds.includes(label.id); return ( @@ -63,6 +65,7 @@ export function InlineLabelEditor({ onToggle(label.id, !isSelected)} /> diff --git a/packages/feature-requests/src/api/requests.ts b/packages/feature-requests/src/api/requests.ts index 83e9a13..841eb7d 100644 --- a/packages/feature-requests/src/api/requests.ts +++ b/packages/feature-requests/src/api/requests.ts @@ -103,7 +103,7 @@ app.get("/:number{[0-9]+}", async (c) => { }); }); -app.get("/", (c) => { +app.get("/", async (c) => { const db = getDb(); const sphereId = c.var.sphereId; const statusParam = c.req.query("status"); @@ -132,17 +132,8 @@ app.get("/", (c) => { number: featureRequests.number, authorDid: featureRequests.authorDid, title: featureRequests.title, - description: featureRequests.description, - category: featureRequests.category, status: featureRequests.status, - duplicateOfId: featureRequests.duplicateOfId, - pdsUri: featureRequests.pdsUri, - updatedAt: featureRequests.updatedAt, voteCount: voteCountCol, - commentCount: - sql`(select count(*) from ${featureRequestComments} where ${featureRequestComments.requestId} = ${featureRequests.id} and ${featureRequestComments.hiddenAt} is null)`.as( - "comment_count", - ), }) .from(featureRequests) .leftJoin(featureRequestVotes, eq(featureRequestVotes.requestId, featureRequests.id)) @@ -152,10 +143,15 @@ app.get("/", (c) => { .all(); const ids = rows.map((r) => r.id); const labelMap = getLabelsForEntities(ids, "feature-request"); + + const uniqueDids = [...new Set(rows.map((r) => r.authorDid))]; + const handleMap = await resolveDidHandles(uniqueDids); + return c.json({ featureRequests: rows.map((r) => ({ ...r, createdAt: tidToDate(r.id), + authorHandle: handleMap.get(r.authorDid) ?? null, labels: labelMap.get(r.id) ?? [], })), }); diff --git a/packages/feature-requests/src/types.ts b/packages/feature-requests/src/types.ts index 6ed6da9..b502e69 100644 --- a/packages/feature-requests/src/types.ts +++ b/packages/feature-requests/src/types.ts @@ -11,8 +11,21 @@ export { import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; import type { LabelInfo } from "@exosphere/core/sphere"; -/** Shape returned by the GET /feature-requests list/detail endpoints (includes vote count). */ -export type FeatureRequestListItem = FeatureRequest & { +/** Shape returned by the GET /feature-requests list endpoint. */ +export type FeatureRequestListItem = { + id: string; + number: number; + authorDid: string; + title: string; + status: string; + createdAt: string; + voteCount: number; + authorHandle?: string | null; + labels: LabelInfo[]; +}; + +/** Shape returned by the GET /feature-requests/:number detail endpoint. */ +export type FeatureRequestDetail = FeatureRequest & { createdAt: string; voteCount: number; commentCount: number; diff --git a/packages/feature-requests/src/ui/api/feature-requests.ts b/packages/feature-requests/src/ui/api/feature-requests.ts index e810bdc..aa87df0 100644 --- a/packages/feature-requests/src/ui/api/feature-requests.ts +++ b/packages/feature-requests/src/ui/api/feature-requests.ts @@ -1,11 +1,11 @@ import { moduleFetch } from "@exosphere/client/module-api"; -import type { FeatureRequest, FeatureRequestListItem } from "../../types.ts"; +import type { FeatureRequest, FeatureRequestListItem, FeatureRequestDetail } from "../../types.ts"; -export type { FeatureRequest, FeatureRequestListItem }; +export type { FeatureRequest, FeatureRequestListItem, FeatureRequestDetail }; export function getFeatureRequest(number: number) { return moduleFetch<{ - featureRequest: FeatureRequestListItem; + featureRequest: FeatureRequestDetail; duplicateOf: { id: string; number: number; title: string } | null; duplicateCount: number; }>(`/feature-requests/${number}`); diff --git a/packages/feature-requests/src/ui/components/request-card.tsx b/packages/feature-requests/src/ui/components/request-card.tsx index 1f0470c..24c9ba4 100644 --- a/packages/feature-requests/src/ui/components/request-card.tsx +++ b/packages/feature-requests/src/ui/components/request-card.tsx @@ -1,4 +1,5 @@ import { useSignal } from "@preact/signals"; +import type { ComponentChildren } from "preact"; import * as ui from "@exosphere/client/ui.css"; import { spherePath } from "@exosphere/client/router"; import { LabelBadge } from "@exosphere/client/components/label-badge"; @@ -8,26 +9,22 @@ import { formatDate } from "@exosphere/client/format"; export function RequestCard({ fr, - isAuthor = false, - canModerate = false, hasVoted, isAuthenticated, isDetail = false, - onDelete, - onHide, + statusSlot, onVote, onUnvote, + children, }: { fr: FeatureRequestListItem; - isAuthor?: boolean; - canModerate?: boolean; hasVoted: boolean; isAuthenticated: boolean; isDetail?: boolean; - onDelete?: (id: string) => void; - onHide?: (id: string) => void; + statusSlot?: ComponentChildren; onVote: (id: string) => void; onUnvote: (id: string) => void; + children?: ComponentChildren; }) { const voting = useSignal(false); @@ -78,40 +75,31 @@ export function RequestCard({ )} + {statusSlot ?? ( + + + {statusLabels[fr.status as Status] ?? fr.status} + + )} +
+ + {children ? ( + children + ) : fr.labels.length > 0 ? (
- {fr.labels?.map((label) => ( + {fr.labels.map((label) => ( ))} - {fr.status !== "requested" && ( - - {statusLabels[fr.status as Status] ?? fr.status} - - )}
-
- {!isDetail &&

{fr.description}

} + ) : null} +
- {isDetail && (fr.authorHandle || fr.authorDid) && ( + {(fr.authorHandle || fr.authorDid) && ( - Proposed by {fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid} + {fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid} )} {formatDate(fr.createdAt)} - {fr.commentCount > 0 && ( - - {fr.commentCount} {fr.commentCount === 1 ? "comment" : "comments"} - - )} - {isAuthor && onDelete && ( - - )} - {canModerate && !isAuthor && onHide && ( - - )}
diff --git a/packages/feature-requests/src/ui/pages/feature-request.tsx b/packages/feature-requests/src/ui/pages/feature-request.tsx index 0132342..92e4f4d 100644 --- a/packages/feature-requests/src/ui/pages/feature-request.tsx +++ b/packages/feature-requests/src/ui/pages/feature-request.tsx @@ -568,6 +568,7 @@ export function FeatureRequestPage() { const availableLabels = useSignal([]); const localLabelIds = useSignal(null); + const savingLabels = useSignal(false); useEffect(() => { if (canChangeStatus.value) { @@ -635,21 +636,23 @@ export function FeatureRequestPage() { }; const handleLabelToggle = async (labelId: string, selected: boolean) => { - if (!fr) return; + if (!fr || savingLabels.value) return; const label = availableLabels.value.find((l) => l.id === labelId); if (!label) return; const prev = fr.labels; const prevIds = prev.map((l) => l.id); const nextIds = selected ? [...prevIds, labelId] : prevIds.filter((id) => id !== labelId); - // Optimistic update const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); fr.labels = nextLabels; localLabelIds.value = nextIds; + savingLabels.value = true; try { await updateFeatureRequestLabels(fr.id, nextIds); } catch { fr.labels = prev; localLabelIds.value = prev.map((l) => l.id); + } finally { + savingLabels.value = false; } }; @@ -675,44 +678,58 @@ export function FeatureRequestPage() {
- - {canChangeStatus.value && ( -
- Status: - -
- )} - - {canChangeStatus.value && availableLabels.value.length > 0 && ( - l.id)} - onToggle={handleLabelToggle} - /> - )} + statusSlot={ + canChangeStatus.value || currentDid === fr.authorDid || canModerate.value ? ( +
+ {currentDid === fr.authorDid && ( + + )} + {canModerate.value && currentDid !== fr.authorDid && ( + + )} + {canChangeStatus.value ? ( + + ) : ( + + + {statusLabels[fr.status as Status] ?? fr.status} + + )} +
+ ) : undefined + } + > + {canChangeStatus.value && availableLabels.value.length > 0 ? ( + l.id)} + onToggle={handleLabelToggle} + disabled={savingLabels.value} + /> + ) : null} +
{data.duplicateOf && (

diff --git a/packages/feature-requests/src/ui/pages/feature-requests.tsx b/packages/feature-requests/src/ui/pages/feature-requests.tsx index 359b630..8c7ffef 100644 --- a/packages/feature-requests/src/ui/pages/feature-requests.tsx +++ b/packages/feature-requests/src/ui/pages/feature-requests.tsx @@ -1,6 +1,5 @@ import { useSignal } from "@preact/signals"; import { auth } from "@exosphere/client/auth"; -import { useCanDo } from "@exosphere/client/permissions"; import { spherePath, useLocation } from "@exosphere/client/router"; import { useQuery } from "@exosphere/client/hooks"; import * as ui from "@exosphere/client/ui.css"; @@ -9,8 +8,6 @@ import { ssrPageData } from "@exosphere/client/ssr-data"; import { getFeatureRequests, createFeatureRequest, - deleteFeatureRequest, - hideFeatureRequest, voteFeatureRequest, unvoteFeatureRequest, getMyVotes, @@ -175,7 +172,6 @@ export function FeatureRequestsListPage() { ); const isAuthenticated = auth.value.authenticated; - const currentDid = isAuthenticated ? auth.value.did : null; const votedIds = useSignal>(new Set(prefetchedVotes?.votes)); // Fetch user's votes when authenticated (skips if SSR-prefetched) @@ -192,25 +188,11 @@ export function FeatureRequestsListPage() { } }, [votesQuery.data]); - const canModerate = useCanDo("feature-requests", "moderate"); - const onCreated = () => { showForm.value = false; refetch(); }; - const handleAction = async (action: (id: string) => Promise, id: string) => { - try { - await action(id); - refetch(); - } catch (err) { - console.error("Action failed:", err); - } - }; - - const handleDelete = (id: string) => handleAction(deleteFeatureRequest, id); - const handleHide = (id: string) => handleAction(hideFeatureRequest, id); - const toggleVote = async (id: string) => { const wasVoted = votedIds.value.has(id); const prev = votedIds.value; @@ -292,12 +274,8 @@ export function FeatureRequestsListPage() { diff --git a/packages/feature-requests/src/ui/ui.css.ts b/packages/feature-requests/src/ui/ui.css.ts index ab062ea..9d69924 100644 --- a/packages/feature-requests/src/ui/ui.css.ts +++ b/packages/feature-requests/src/ui/ui.css.ts @@ -1,4 +1,4 @@ -import { style } from "@vanilla-extract/css"; +import { globalStyle, style } from "@vanilla-extract/css"; import { vars } from "@exosphere/client/theme.css"; // ---- Vote buttons ---- @@ -173,3 +173,52 @@ export const commentEditArea = style({ export const pendingFade = style({ opacity: 0.6, }); + +// ---- Status indicator ---- + +export const statusIndicator = style({ + display: "inline-flex", + alignItems: "center", + gap: "6px", + fontSize: "0.8125rem", + fontWeight: 500, + whiteSpace: "nowrap", + flexShrink: 0, + color: vars.color.textMuted, +}); + +export const statusDot = style({ + display: "inline-block", + inlineSize: "6px", + blockSize: "6px", + borderRadius: "50%", + backgroundColor: vars.color.textMuted, +}); + +globalStyle(`${statusDot}[data-status="approved"]`, { + backgroundColor: vars.color.primary, +}); +globalStyle(`${statusIndicator}[data-status="approved"]`, { + color: vars.color.primary, +}); + +globalStyle(`${statusDot}[data-status="in-progress"]`, { + backgroundColor: vars.color.warning, +}); +globalStyle(`${statusIndicator}[data-status="in-progress"]`, { + color: vars.color.warning, +}); + +globalStyle(`${statusDot}[data-status="done"]`, { + backgroundColor: vars.color.success, +}); +globalStyle(`${statusIndicator}[data-status="done"]`, { + color: vars.color.success, +}); + +globalStyle(`${statusDot}[data-status="not-planned"]`, { + backgroundColor: vars.color.danger, +}); +globalStyle(`${statusIndicator}[data-status="not-planned"]`, { + color: vars.color.danger, +}); diff --git a/packages/kanban/src/ui/pages/task.tsx b/packages/kanban/src/ui/pages/task.tsx index 3bb5719..0876b79 100644 --- a/packages/kanban/src/ui/pages/task.tsx +++ b/packages/kanban/src/ui/pages/task.tsx @@ -7,6 +7,7 @@ import { ssrPageData } from "@exosphere/client/ssr-data"; import { formatDate } from "@exosphere/client/format"; import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; import { InlineLabelEditor } from "@exosphere/client/components/inline-label-editor"; +import { LabelBadge } from "@exosphere/client/components/label-badge"; import { getLabels, type LabelData } from "@exosphere/client/api/labels"; import * as ui from "@exosphere/client/ui.css"; import * as kbUi from "../ui.css.ts"; @@ -395,6 +396,7 @@ export function TaskPage() { const availableLabels = useSignal([]); const localLabelIds = useSignal(null); + const savingLabels = useSignal(false); useEffect(() => { if (canManage.value) { @@ -465,7 +467,7 @@ export function TaskPage() { }; const handleLabelToggle = async (labelId: string, selected: boolean) => { - if (!task) return; + if (!task || savingLabels.value) return; const label = availableLabels.value.find((l) => l.id === labelId); if (!label) return; const prev = task.labels; @@ -474,11 +476,14 @@ export function TaskPage() { const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); task.labels = nextLabels; localLabelIds.value = nextIds; + savingLabels.value = true; try { await updateTaskLabels(task.id, nextIds); } catch { task.labels = prev; localLabelIds.value = prev.map((l) => l.id); + } finally { + savingLabels.value = false; } }; @@ -553,9 +558,6 @@ export function TaskPage() { <>

#{task.number} - - {statusLabel(localStatus.value ?? task.status, cols)} - {task.assigneeHandle && @{task.assigneeHandle}} {formatDate(task.createdAt, fullDateOpts)}
@@ -578,6 +580,45 @@ export function TaskPage() { )}
+ +
+ Status: + {canChangeStatus.value ? ( + + ) : ( + + {statusLabel(localStatus.value ?? task.status, cols)} + + )} +
+ + {canManage.value && availableLabels.value.length > 0 ? ( + l.id)} + onToggle={handleLabelToggle} + disabled={savingLabels.value} + /> + ) : task.labels.length > 0 ? ( +
+ Labels +
+ {task.labels.map((label) => ( + + ))} +
+
+ ) : null} )} @@ -585,31 +626,6 @@ export function TaskPage() { {!editing.value && task.description && (
{task.description}
)} - - {canChangeStatus.value && !editing.value && ( -
- Status: - -
- )} - - {canManage.value && !editing.value && availableLabels.value.length > 0 && ( - l.id)} - onToggle={handleLabelToggle} - /> - )}