Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

ui: improve feature request card and labels handling

Hugo 1c11a0d4 5625ba52

+245 -172
+31 -18
packages/client/src/components/inline-label-editor.css.ts
··· 3 3 4 4 export const wrapper = style({ 5 5 position: "relative", 6 - display: "flex", 7 - flexWrap: "wrap", 8 - alignItems: "center", 9 - gap: vars.space.sm, 10 6 }); 11 7 12 8 export const editButton = style({ 13 9 display: "inline-flex", 14 10 alignItems: "center", 15 - justifyContent: "center", 11 + gap: "4px", 16 12 background: "none", 17 13 border: "none", 18 14 color: vars.color.textMuted, 15 + fontSize: "0.8125rem", 16 + fontFamily: vars.font.body, 19 17 cursor: "pointer", 20 - padding: vars.space.xs, 21 - borderRadius: vars.radius.sm, 22 - lineHeight: 1, 23 - fontSize: "0.875rem", 18 + padding: 0, 24 19 transition: "color 0.15s", 25 20 ":hover": { 26 - color: vars.color.text, 21 + color: vars.color.primary, 27 22 }, 28 23 }); 29 24 25 + export const labels = style({ 26 + display: "flex", 27 + alignItems: "center", 28 + flexWrap: "wrap", 29 + gap: vars.space.sm, 30 + }); 31 + 30 32 export const dropdown = style({ 31 33 position: "absolute", 32 34 zIndex: 10, 33 35 insetBlockStart: "100%", 34 - insetInlineEnd: 0, 35 - marginBlockStart: vars.space.xs, 36 - minInlineSize: "200px", 37 - maxBlockSize: "240px", 36 + insetInlineStart: 0, 37 + marginBlockStart: vars.space.sm, 38 + minInlineSize: "220px", 39 + maxBlockSize: "280px", 38 40 overflowY: "auto", 39 41 backgroundColor: vars.color.surface, 40 42 border: `1px solid ${vars.color.border}`, 41 43 borderRadius: vars.radius.sm, 42 - boxShadow: `0 4px 12px ${vars.color.shadow}`, 43 - paddingBlock: vars.space.xs, 44 + boxShadow: `0 4px 16px ${vars.color.shadow}`, 45 + }); 46 + 47 + export const dropdownHeader = style({ 48 + paddingBlock: vars.space.sm, 49 + paddingInline: vars.space.md, 50 + fontSize: "0.75rem", 51 + fontWeight: 600, 52 + color: vars.color.textMuted, 53 + textTransform: "uppercase", 54 + letterSpacing: "0.04em", 55 + borderBlockEnd: `1px solid ${vars.color.border}`, 44 56 }); 45 57 46 58 export const option = style({ 47 59 display: "flex", 48 60 alignItems: "center", 49 61 gap: vars.space.sm, 50 - paddingBlock: vars.space.xs, 51 - paddingInline: vars.space.sm, 62 + paddingBlock: vars.space.sm, 63 + paddingInline: vars.space.md, 52 64 cursor: "pointer", 65 + fontSize: "0.875rem", 53 66 transition: "background-color 0.1s", 54 67 ":hover": { 55 68 backgroundColor: vars.color.surfaceHover,
+22 -19
packages/client/src/components/inline-label-editor.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { useEffect, useRef } from "preact/hooks"; 3 - import * as ui from "../ui.css.ts"; 4 3 import * as s from "./inline-label-editor.css.ts"; 5 4 import { LabelBadge } from "./label-badge.tsx"; 6 5 import type { LabelOption } from "./label-picker.tsx"; ··· 10 9 labels, 11 10 selectedIds, 12 11 onToggle, 12 + disabled = false, 13 13 }: { 14 14 labels: LabelOption[]; 15 15 selectedIds: string[]; 16 16 onToggle: (labelId: string, selected: boolean) => void; 17 + disabled?: boolean; 17 18 }) { 18 19 const open = useSignal(false); 19 20 const wrapperRef = useRef<HTMLDivElement>(null); ··· 35 36 36 37 return ( 37 38 <div class={s.wrapper} ref={wrapperRef}> 38 - <span class={ui.muted}>Labels:</span> 39 - {selectedLabels.length > 0 ? ( 40 - <span class={ui.cluster}> 41 - {selectedLabels.map((l) => ( 42 - <LabelBadge key={l.id} label={l} /> 43 - ))} 44 - </span> 45 - ) : ( 46 - <span class={ui.muted}>None</span> 47 - )} 48 - <button 49 - type="button" 50 - class={s.editButton} 51 - onClick={() => (open.value = !open.value)} 52 - title="Edit labels" 53 - > 54 - {"\u2699"} 55 - </button> 39 + <div class={s.labels}> 40 + <button 41 + type="button" 42 + class={s.editButton} 43 + onClick={() => (open.value = !open.value)} 44 + title="Edit labels" 45 + > 46 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 47 + <path d="m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19" /> 48 + <path d="M9.586 5.586A2 2 0 0 0 8.172 5H3a1 1 0 0 0-1 1v5.172a2 2 0 0 0 .586 1.414L8.29 18.29a2.426 2.426 0 0 0 3.42 0l3.58-3.58a2.426 2.426 0 0 0 0-3.42z" /> 49 + <circle cx="6.5" cy="9.5" r=".5" fill="currentColor" /> 50 + </svg> 51 + {open.value ? "Done" : "Edit"} 52 + </button> 53 + {selectedLabels.map((l) => ( 54 + <LabelBadge key={l.id} label={l} /> 55 + ))} 56 + </div> 56 57 57 58 {open.value && ( 58 59 <div class={s.dropdown}> 60 + <div class={s.dropdownHeader}>Apply labels</div> 59 61 {labels.map((label) => { 60 62 const isSelected = selectedIds.includes(label.id); 61 63 return ( ··· 63 65 <input 64 66 type="checkbox" 65 67 checked={isSelected} 68 + disabled={disabled} 66 69 onChange={() => onToggle(label.id, !isSelected)} 67 70 /> 68 71 <LabelBadge label={label} />
+6 -10
packages/feature-requests/src/api/requests.ts
··· 103 103 }); 104 104 }); 105 105 106 - app.get("/", (c) => { 106 + app.get("/", async (c) => { 107 107 const db = getDb(); 108 108 const sphereId = c.var.sphereId; 109 109 const statusParam = c.req.query("status"); ··· 132 132 number: featureRequests.number, 133 133 authorDid: featureRequests.authorDid, 134 134 title: featureRequests.title, 135 - description: featureRequests.description, 136 - category: featureRequests.category, 137 135 status: featureRequests.status, 138 - duplicateOfId: featureRequests.duplicateOfId, 139 - pdsUri: featureRequests.pdsUri, 140 - updatedAt: featureRequests.updatedAt, 141 136 voteCount: voteCountCol, 142 - commentCount: 143 - sql<number>`(select count(*) from ${featureRequestComments} where ${featureRequestComments.requestId} = ${featureRequests.id} and ${featureRequestComments.hiddenAt} is null)`.as( 144 - "comment_count", 145 - ), 146 137 }) 147 138 .from(featureRequests) 148 139 .leftJoin(featureRequestVotes, eq(featureRequestVotes.requestId, featureRequests.id)) ··· 152 143 .all(); 153 144 const ids = rows.map((r) => r.id); 154 145 const labelMap = getLabelsForEntities(ids, "feature-request"); 146 + 147 + const uniqueDids = [...new Set(rows.map((r) => r.authorDid))]; 148 + const handleMap = await resolveDidHandles(uniqueDids); 149 + 155 150 return c.json({ 156 151 featureRequests: rows.map((r) => ({ 157 152 ...r, 158 153 createdAt: tidToDate(r.id), 154 + authorHandle: handleMap.get(r.authorDid) ?? null, 159 155 labels: labelMap.get(r.id) ?? [], 160 156 })), 161 157 });
+15 -2
packages/feature-requests/src/types.ts
··· 11 11 import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; 12 12 import type { LabelInfo } from "@exosphere/core/sphere"; 13 13 14 - /** Shape returned by the GET /feature-requests list/detail endpoints (includes vote count). */ 15 - export type FeatureRequestListItem = FeatureRequest & { 14 + /** Shape returned by the GET /feature-requests list endpoint. */ 15 + export type FeatureRequestListItem = { 16 + id: string; 17 + number: number; 18 + authorDid: string; 19 + title: string; 20 + status: string; 21 + createdAt: string; 22 + voteCount: number; 23 + authorHandle?: string | null; 24 + labels: LabelInfo[]; 25 + }; 26 + 27 + /** Shape returned by the GET /feature-requests/:number detail endpoint. */ 28 + export type FeatureRequestDetail = FeatureRequest & { 16 29 createdAt: string; 17 30 voteCount: number; 18 31 commentCount: number;
+3 -3
packages/feature-requests/src/ui/api/feature-requests.ts
··· 1 1 import { moduleFetch } from "@exosphere/client/module-api"; 2 - import type { FeatureRequest, FeatureRequestListItem } from "../../types.ts"; 2 + import type { FeatureRequest, FeatureRequestListItem, FeatureRequestDetail } from "../../types.ts"; 3 3 4 - export type { FeatureRequest, FeatureRequestListItem }; 4 + export type { FeatureRequest, FeatureRequestListItem, FeatureRequestDetail }; 5 5 6 6 export function getFeatureRequest(number: number) { 7 7 return moduleFetch<{ 8 - featureRequest: FeatureRequestListItem; 8 + featureRequest: FeatureRequestDetail; 9 9 duplicateOf: { id: string; number: number; title: string } | null; 10 10 duplicateCount: number; 11 11 }>(`/feature-requests/${number}`);
+21 -33
packages/feature-requests/src/ui/components/request-card.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 + import type { ComponentChildren } from "preact"; 2 3 import * as ui from "@exosphere/client/ui.css"; 3 4 import { spherePath } from "@exosphere/client/router"; 4 5 import { LabelBadge } from "@exosphere/client/components/label-badge"; ··· 8 9 9 10 export function RequestCard({ 10 11 fr, 11 - isAuthor = false, 12 - canModerate = false, 13 12 hasVoted, 14 13 isAuthenticated, 15 14 isDetail = false, 16 - onDelete, 17 - onHide, 15 + statusSlot, 18 16 onVote, 19 17 onUnvote, 18 + children, 20 19 }: { 21 20 fr: FeatureRequestListItem; 22 - isAuthor?: boolean; 23 - canModerate?: boolean; 24 21 hasVoted: boolean; 25 22 isAuthenticated: boolean; 26 23 isDetail?: boolean; 27 - onDelete?: (id: string) => void; 28 - onHide?: (id: string) => void; 24 + statusSlot?: ComponentChildren; 29 25 onVote: (id: string) => void; 30 26 onUnvote: (id: string) => void; 27 + children?: ComponentChildren; 31 28 }) { 32 29 const voting = useSignal(false); 33 30 ··· 78 75 </a> 79 76 </h3> 80 77 )} 78 + {statusSlot ?? ( 79 + <span class={frUi.statusIndicator} data-status={fr.status}> 80 + <span class={frUi.statusDot} data-status={fr.status} /> 81 + {statusLabels[fr.status as Status] ?? fr.status} 82 + </span> 83 + )} 84 + </div> 85 + 86 + {children ? ( 87 + children 88 + ) : fr.labels.length > 0 ? ( 81 89 <div class={ui.cluster}> 82 - {fr.labels?.map((label) => ( 90 + {fr.labels.map((label) => ( 83 91 <LabelBadge key={label.id} label={label} /> 84 92 ))} 85 - {fr.status !== "requested" && ( 86 - <span class={ui.badge} data-status={fr.status}> 87 - {statusLabels[fr.status as Status] ?? fr.status} 88 - </span> 89 - )} 90 93 </div> 91 - </div> 92 - {!isDetail && <p class={ui.description}>{fr.description}</p>} 94 + ) : null} 95 + 93 96 <div class={ui.metaRow}> 94 - {isDetail && (fr.authorHandle || fr.authorDid) && ( 97 + {(fr.authorHandle || fr.authorDid) && ( 95 98 <span class={ui.muted}> 96 - Proposed by {fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid} 99 + {fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid} 97 100 </span> 98 101 )} 99 102 <span class={ui.muted}>{formatDate(fr.createdAt)}</span> 100 - {fr.commentCount > 0 && ( 101 - <span class={ui.muted}> 102 - {fr.commentCount} {fr.commentCount === 1 ? "comment" : "comments"} 103 - </span> 104 - )} 105 - {isAuthor && onDelete && ( 106 - <button class={ui.buttonDangerInline} onClick={() => onDelete(fr.id)}> 107 - Delete 108 - </button> 109 - )} 110 - {canModerate && !isAuthor && onHide && ( 111 - <button class={ui.buttonDangerInline} onClick={() => onHide(fr.id)}> 112 - Hide 113 - </button> 114 - )} 115 103 </div> 116 104 </div> 117 105 </div>
+52 -35
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 568 568 569 569 const availableLabels = useSignal<LabelData[]>([]); 570 570 const localLabelIds = useSignal<string[] | null>(null); 571 + const savingLabels = useSignal(false); 571 572 572 573 useEffect(() => { 573 574 if (canChangeStatus.value) { ··· 635 636 }; 636 637 637 638 const handleLabelToggle = async (labelId: string, selected: boolean) => { 638 - if (!fr) return; 639 + if (!fr || savingLabels.value) return; 639 640 const label = availableLabels.value.find((l) => l.id === labelId); 640 641 if (!label) return; 641 642 const prev = fr.labels; 642 643 const prevIds = prev.map((l) => l.id); 643 644 const nextIds = selected ? [...prevIds, labelId] : prevIds.filter((id) => id !== labelId); 644 - // Optimistic update 645 645 const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); 646 646 fr.labels = nextLabels; 647 647 localLabelIds.value = nextIds; 648 + savingLabels.value = true; 648 649 try { 649 650 await updateFeatureRequestLabels(fr.id, nextIds); 650 651 } catch { 651 652 fr.labels = prev; 652 653 localLabelIds.value = prev.map((l) => l.id); 654 + } finally { 655 + savingLabels.value = false; 653 656 } 654 657 }; 655 658 ··· 675 678 <div class={ui.stackLg}> 676 679 <RequestCard 677 680 fr={fr} 678 - isAuthor={currentDid === fr.authorDid} 679 - canModerate={canModerate.value} 680 681 hasVoted={votedIds.value.has(fr.id)} 681 682 isAuthenticated={isAuthenticated} 682 683 isDetail 683 - onDelete={handleDelete} 684 - onHide={handleHide} 685 684 onVote={toggleVote} 686 685 onUnvote={toggleVote} 687 - /> 688 - 689 - {canChangeStatus.value && ( 690 - <div class={ui.metaRow}> 691 - <span class={ui.muted}>Status:</span> 692 - <select 693 - class={frUi.sortSelect} 694 - value={fr.status} 695 - onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 696 - > 697 - {fr.status === "duplicate" && ( 698 - <option value="duplicate">{statusLabels.duplicate}</option> 699 - )} 700 - {settableStatuses.map((s) => ( 701 - <option key={s} value={s}> 702 - {statusLabels[s]} 703 - </option> 704 - ))} 705 - </select> 706 - </div> 707 - )} 708 - 709 - {canChangeStatus.value && availableLabels.value.length > 0 && ( 710 - <InlineLabelEditor 711 - labels={availableLabels.value} 712 - selectedIds={localLabelIds.value ?? fr.labels.map((l) => l.id)} 713 - onToggle={handleLabelToggle} 714 - /> 715 - )} 686 + statusSlot={ 687 + canChangeStatus.value || currentDid === fr.authorDid || canModerate.value ? ( 688 + <div class={ui.cluster}> 689 + {currentDid === fr.authorDid && ( 690 + <button class={ui.buttonDangerInline} onClick={handleDelete}> 691 + Delete 692 + </button> 693 + )} 694 + {canModerate.value && currentDid !== fr.authorDid && ( 695 + <button class={ui.buttonDangerInline} onClick={handleHide}> 696 + Hide 697 + </button> 698 + )} 699 + {canChangeStatus.value ? ( 700 + <select 701 + class={frUi.sortSelect} 702 + value={fr.status} 703 + onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 704 + > 705 + {fr.status === "duplicate" && ( 706 + <option value="duplicate">{statusLabels.duplicate}</option> 707 + )} 708 + {settableStatuses.map((s) => ( 709 + <option key={s} value={s}> 710 + {statusLabels[s]} 711 + </option> 712 + ))} 713 + </select> 714 + ) : ( 715 + <span class={frUi.statusIndicator} data-status={fr.status}> 716 + <span class={frUi.statusDot} data-status={fr.status} /> 717 + {statusLabels[fr.status as Status] ?? fr.status} 718 + </span> 719 + )} 720 + </div> 721 + ) : undefined 722 + } 723 + > 724 + {canChangeStatus.value && availableLabels.value.length > 0 ? ( 725 + <InlineLabelEditor 726 + labels={availableLabels.value} 727 + selectedIds={localLabelIds.value ?? fr.labels.map((l) => l.id)} 728 + onToggle={handleLabelToggle} 729 + disabled={savingLabels.value} 730 + /> 731 + ) : null} 732 + </RequestCard> 716 733 717 734 {data.duplicateOf && ( 718 735 <p class={ui.muted}>
-22
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 - import { useCanDo } from "@exosphere/client/permissions"; 4 3 import { spherePath, useLocation } from "@exosphere/client/router"; 5 4 import { useQuery } from "@exosphere/client/hooks"; 6 5 import * as ui from "@exosphere/client/ui.css"; ··· 9 8 import { 10 9 getFeatureRequests, 11 10 createFeatureRequest, 12 - deleteFeatureRequest, 13 - hideFeatureRequest, 14 11 voteFeatureRequest, 15 12 unvoteFeatureRequest, 16 13 getMyVotes, ··· 175 172 ); 176 173 177 174 const isAuthenticated = auth.value.authenticated; 178 - const currentDid = isAuthenticated ? auth.value.did : null; 179 175 180 176 const votedIds = useSignal<Set<string>>(new Set(prefetchedVotes?.votes)); 181 177 // Fetch user's votes when authenticated (skips if SSR-prefetched) ··· 192 188 } 193 189 }, [votesQuery.data]); 194 190 195 - const canModerate = useCanDo("feature-requests", "moderate"); 196 - 197 191 const onCreated = () => { 198 192 showForm.value = false; 199 193 refetch(); 200 194 }; 201 - 202 - const handleAction = async (action: (id: string) => Promise<unknown>, id: string) => { 203 - try { 204 - await action(id); 205 - refetch(); 206 - } catch (err) { 207 - console.error("Action failed:", err); 208 - } 209 - }; 210 - 211 - const handleDelete = (id: string) => handleAction(deleteFeatureRequest, id); 212 - const handleHide = (id: string) => handleAction(hideFeatureRequest, id); 213 195 214 196 const toggleVote = async (id: string) => { 215 197 const wasVoted = votedIds.value.has(id); ··· 292 274 <RequestCard 293 275 key={fr.id} 294 276 fr={fr} 295 - isAuthor={currentDid === fr.authorDid} 296 - canModerate={canModerate.value} 297 277 hasVoted={votedIds.value.has(fr.id)} 298 278 isAuthenticated={isAuthenticated} 299 - onDelete={handleDelete} 300 - onHide={handleHide} 301 279 onVote={toggleVote} 302 280 onUnvote={toggleVote} 303 281 />
+50 -1
packages/feature-requests/src/ui/ui.css.ts
··· 1 - import { style } from "@vanilla-extract/css"; 1 + import { globalStyle, style } from "@vanilla-extract/css"; 2 2 import { vars } from "@exosphere/client/theme.css"; 3 3 4 4 // ---- Vote buttons ---- ··· 173 173 export const pendingFade = style({ 174 174 opacity: 0.6, 175 175 }); 176 + 177 + // ---- Status indicator ---- 178 + 179 + export const statusIndicator = style({ 180 + display: "inline-flex", 181 + alignItems: "center", 182 + gap: "6px", 183 + fontSize: "0.8125rem", 184 + fontWeight: 500, 185 + whiteSpace: "nowrap", 186 + flexShrink: 0, 187 + color: vars.color.textMuted, 188 + }); 189 + 190 + export const statusDot = style({ 191 + display: "inline-block", 192 + inlineSize: "6px", 193 + blockSize: "6px", 194 + borderRadius: "50%", 195 + backgroundColor: vars.color.textMuted, 196 + }); 197 + 198 + globalStyle(`${statusDot}[data-status="approved"]`, { 199 + backgroundColor: vars.color.primary, 200 + }); 201 + globalStyle(`${statusIndicator}[data-status="approved"]`, { 202 + color: vars.color.primary, 203 + }); 204 + 205 + globalStyle(`${statusDot}[data-status="in-progress"]`, { 206 + backgroundColor: vars.color.warning, 207 + }); 208 + globalStyle(`${statusIndicator}[data-status="in-progress"]`, { 209 + color: vars.color.warning, 210 + }); 211 + 212 + globalStyle(`${statusDot}[data-status="done"]`, { 213 + backgroundColor: vars.color.success, 214 + }); 215 + globalStyle(`${statusIndicator}[data-status="done"]`, { 216 + color: vars.color.success, 217 + }); 218 + 219 + globalStyle(`${statusDot}[data-status="not-planned"]`, { 220 + backgroundColor: vars.color.danger, 221 + }); 222 + globalStyle(`${statusIndicator}[data-status="not-planned"]`, { 223 + color: vars.color.danger, 224 + });
+45 -29
packages/kanban/src/ui/pages/task.tsx
··· 7 7 import { formatDate } from "@exosphere/client/format"; 8 8 import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 9 9 import { InlineLabelEditor } from "@exosphere/client/components/inline-label-editor"; 10 + import { LabelBadge } from "@exosphere/client/components/label-badge"; 10 11 import { getLabels, type LabelData } from "@exosphere/client/api/labels"; 11 12 import * as ui from "@exosphere/client/ui.css"; 12 13 import * as kbUi from "../ui.css.ts"; ··· 395 396 396 397 const availableLabels = useSignal<LabelData[]>([]); 397 398 const localLabelIds = useSignal<string[] | null>(null); 399 + const savingLabels = useSignal(false); 398 400 399 401 useEffect(() => { 400 402 if (canManage.value) { ··· 465 467 }; 466 468 467 469 const handleLabelToggle = async (labelId: string, selected: boolean) => { 468 - if (!task) return; 470 + if (!task || savingLabels.value) return; 469 471 const label = availableLabels.value.find((l) => l.id === labelId); 470 472 if (!label) return; 471 473 const prev = task.labels; ··· 474 476 const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); 475 477 task.labels = nextLabels; 476 478 localLabelIds.value = nextIds; 479 + savingLabels.value = true; 477 480 try { 478 481 await updateTaskLabels(task.id, nextIds); 479 482 } catch { 480 483 task.labels = prev; 481 484 localLabelIds.value = prev.map((l) => l.id); 485 + } finally { 486 + savingLabels.value = false; 482 487 } 483 488 }; 484 489 ··· 553 558 <> 554 559 <div class={ui.metaRow}> 555 560 <span class={ui.muted}>#{task.number}</span> 556 - <span class={ui.badge}> 557 - {statusLabel(localStatus.value ?? task.status, cols)} 558 - </span> 559 561 {task.assigneeHandle && <span class={ui.muted}>@{task.assigneeHandle}</span>} 560 562 <span class={ui.muted}>{formatDate(task.createdAt, fullDateOpts)}</span> 561 563 </div> ··· 578 580 </button> 579 581 )} 580 582 </div> 583 + 584 + <div class={ui.metaRow}> 585 + <span class={ui.muted}>Status:</span> 586 + {canChangeStatus.value ? ( 587 + <select 588 + class={kbUi.statusSelect} 589 + value={localStatus.value ?? task.status} 590 + onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 591 + > 592 + {cols.map((col) => ( 593 + <option key={col.slug} value={col.slug}> 594 + {col.label} 595 + </option> 596 + ))} 597 + </select> 598 + ) : ( 599 + <span class={ui.badge}> 600 + {statusLabel(localStatus.value ?? task.status, cols)} 601 + </span> 602 + )} 603 + </div> 604 + 605 + {canManage.value && availableLabels.value.length > 0 ? ( 606 + <InlineLabelEditor 607 + labels={availableLabels.value} 608 + selectedIds={localLabelIds.value ?? task.labels.map((l) => l.id)} 609 + onToggle={handleLabelToggle} 610 + disabled={savingLabels.value} 611 + /> 612 + ) : task.labels.length > 0 ? ( 613 + <div class={ui.metaRow}> 614 + <span class={ui.muted}>Labels</span> 615 + <div class={ui.cluster}> 616 + {task.labels.map((label) => ( 617 + <LabelBadge key={label.id} label={label} /> 618 + ))} 619 + </div> 620 + </div> 621 + ) : null} 581 622 </> 582 623 )} 583 624 </div> 584 625 585 626 {!editing.value && task.description && ( 586 627 <div class={kbUi.descriptionBlock}>{task.description}</div> 587 - )} 588 - 589 - {canChangeStatus.value && !editing.value && ( 590 - <div class={ui.metaRow}> 591 - <span class={ui.muted}>Status:</span> 592 - <select 593 - class={kbUi.statusSelect} 594 - value={localStatus.value ?? task.status} 595 - onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 596 - > 597 - {cols.map((col) => ( 598 - <option key={col.slug} value={col.slug}> 599 - {col.label} 600 - </option> 601 - ))} 602 - </select> 603 - </div> 604 - )} 605 - 606 - {canManage.value && !editing.value && availableLabels.value.length > 0 && ( 607 - <InlineLabelEditor 608 - labels={availableLabels.value} 609 - selectedIds={localLabelIds.value ?? task.labels.map((l) => l.id)} 610 - onToggle={handleLabelToggle} 611 - /> 612 628 )} 613 629 </div> 614 630