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.

feat: label update

Hugo 5625ba52 102c6f26

+233 -1
+1
packages/client/package.json
··· 23 23 "./components/theme-toggle": "./src/components/theme-toggle.tsx", 24 24 "./components/label-badge": "./src/components/label-badge.tsx", 25 25 "./components/label-picker": "./src/components/label-picker.tsx", 26 + "./components/inline-label-editor": "./src/components/inline-label-editor.tsx", 26 27 "./api/labels": "./src/api/labels.ts" 27 28 }, 28 29 "peerDependencies": {
+57
packages/client/src/components/inline-label-editor.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + 4 + export const wrapper = style({ 5 + position: "relative", 6 + display: "flex", 7 + flexWrap: "wrap", 8 + alignItems: "center", 9 + gap: vars.space.sm, 10 + }); 11 + 12 + export const editButton = style({ 13 + display: "inline-flex", 14 + alignItems: "center", 15 + justifyContent: "center", 16 + background: "none", 17 + border: "none", 18 + color: vars.color.textMuted, 19 + cursor: "pointer", 20 + padding: vars.space.xs, 21 + borderRadius: vars.radius.sm, 22 + lineHeight: 1, 23 + fontSize: "0.875rem", 24 + transition: "color 0.15s", 25 + ":hover": { 26 + color: vars.color.text, 27 + }, 28 + }); 29 + 30 + export const dropdown = style({ 31 + position: "absolute", 32 + zIndex: 10, 33 + insetBlockStart: "100%", 34 + insetInlineEnd: 0, 35 + marginBlockStart: vars.space.xs, 36 + minInlineSize: "200px", 37 + maxBlockSize: "240px", 38 + overflowY: "auto", 39 + backgroundColor: vars.color.surface, 40 + border: `1px solid ${vars.color.border}`, 41 + borderRadius: vars.radius.sm, 42 + boxShadow: `0 4px 12px ${vars.color.shadow}`, 43 + paddingBlock: vars.space.xs, 44 + }); 45 + 46 + export const option = style({ 47 + display: "flex", 48 + alignItems: "center", 49 + gap: vars.space.sm, 50 + paddingBlock: vars.space.xs, 51 + paddingInline: vars.space.sm, 52 + cursor: "pointer", 53 + transition: "background-color 0.1s", 54 + ":hover": { 55 + backgroundColor: vars.color.surfaceHover, 56 + }, 57 + });
+76
packages/client/src/components/inline-label-editor.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect, useRef } from "preact/hooks"; 3 + import * as ui from "../ui.css.ts"; 4 + import * as s from "./inline-label-editor.css.ts"; 5 + import { LabelBadge } from "./label-badge.tsx"; 6 + import type { LabelOption } from "./label-picker.tsx"; 7 + export type { LabelOption }; 8 + 9 + export function InlineLabelEditor({ 10 + labels, 11 + selectedIds, 12 + onToggle, 13 + }: { 14 + labels: LabelOption[]; 15 + selectedIds: string[]; 16 + onToggle: (labelId: string, selected: boolean) => void; 17 + }) { 18 + const open = useSignal(false); 19 + const wrapperRef = useRef<HTMLDivElement>(null); 20 + 21 + useEffect(() => { 22 + if (!open.value) return; 23 + const handler = (e: MouseEvent) => { 24 + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { 25 + open.value = false; 26 + } 27 + }; 28 + document.addEventListener("mousedown", handler); 29 + return () => document.removeEventListener("mousedown", handler); 30 + }, [open.value]); 31 + 32 + if (labels.length === 0) return null; 33 + 34 + const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); 35 + 36 + return ( 37 + <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> 56 + 57 + {open.value && ( 58 + <div class={s.dropdown}> 59 + {labels.map((label) => { 60 + const isSelected = selectedIds.includes(label.id); 61 + return ( 62 + <label key={label.id} class={s.option}> 63 + <input 64 + type="checkbox" 65 + checked={isSelected} 66 + onChange={() => onToggle(label.id, !isSelected)} 67 + /> 68 + <LabelBadge label={label} /> 69 + </label> 70 + ); 71 + })} 72 + </div> 73 + )} 74 + </div> 75 + ); 76 + }
+41
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 22 22 unvoteComment, 23 23 getMyCommentVotes, 24 24 updateFeatureRequestStatus, 25 + updateFeatureRequestLabels, 25 26 getStatusHistory, 26 27 getDuplicates, 27 28 settableStatuses, ··· 34 35 import { SortControls } from "../components/sort-controls.tsx"; 35 36 import { useSortParams } from "../hooks/use-sort-params.ts"; 36 37 import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 38 + import { InlineLabelEditor } from "@exosphere/client/components/inline-label-editor"; 39 + import { getLabels, type LabelData } from "@exosphere/client/api/labels"; 37 40 import { useEffect, useRef } from "preact/hooks"; 38 41 import { formatDate } from "@exosphere/client/format"; 39 42 ··· 563 566 const canChangeStatus = useCanDo("feature-requests", "changeStatus"); 564 567 const canMarkDuplicate = useCanDo("feature-requests", "markDuplicate"); 565 568 569 + const availableLabels = useSignal<LabelData[]>([]); 570 + const localLabelIds = useSignal<string[] | null>(null); 571 + 572 + useEffect(() => { 573 + if (canChangeStatus.value) { 574 + getLabels() 575 + .then((res) => (availableLabels.value = res.labels)) 576 + .catch(() => {}); 577 + } 578 + }, [canChangeStatus.value]); 579 + 566 580 const fr = data?.featureRequest; 567 581 568 582 const handleDelete = async () => { ··· 620 634 } 621 635 }; 622 636 637 + const handleLabelToggle = async (labelId: string, selected: boolean) => { 638 + if (!fr) return; 639 + const label = availableLabels.value.find((l) => l.id === labelId); 640 + if (!label) return; 641 + const prev = fr.labels; 642 + const prevIds = prev.map((l) => l.id); 643 + const nextIds = selected ? [...prevIds, labelId] : prevIds.filter((id) => id !== labelId); 644 + // Optimistic update 645 + const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); 646 + fr.labels = nextLabels; 647 + localLabelIds.value = nextIds; 648 + try { 649 + await updateFeatureRequestLabels(fr.id, nextIds); 650 + } catch { 651 + fr.labels = prev; 652 + localLabelIds.value = prev.map((l) => l.id); 653 + } 654 + }; 655 + 623 656 return ( 624 657 <div class={ui.container}> 625 658 <div class={ui.section}> ··· 671 704 ))} 672 705 </select> 673 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 + /> 674 715 )} 675 716 676 717 {data.duplicateOf && (
+11
packages/kanban/src/ui/api/tasks.ts
··· 79 79 }); 80 80 } 81 81 82 + export function updateTaskLabels(id: string, labelIds: string[]) { 83 + return moduleFetch<{ labels: Array<{ id: string; name: string; color: string }> }>( 84 + `/kanban/${encodeURIComponent(id)}/labels`, 85 + { 86 + method: "POST", 87 + headers: { "Content-Type": "application/json" }, 88 + body: JSON.stringify({ labelIds }), 89 + }, 90 + ); 91 + } 92 + 82 93 export function getStatusHistory(taskId: string) { 83 94 return moduleFetch<{ 84 95 statuses: Array<{
+47 -1
packages/kanban/src/ui/pages/task.tsx
··· 6 6 import { ssrPageData } from "@exosphere/client/ssr-data"; 7 7 import { formatDate } from "@exosphere/client/format"; 8 8 import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 9 + import { InlineLabelEditor } from "@exosphere/client/components/inline-label-editor"; 10 + import { getLabels, type LabelData } from "@exosphere/client/api/labels"; 9 11 import * as ui from "@exosphere/client/ui.css"; 10 12 import * as kbUi from "../ui.css.ts"; 11 - import { getTask, updateTask, deleteTask, updateTaskStatus, hideTask } from "../api/tasks.ts"; 13 + import { 14 + getTask, 15 + updateTask, 16 + deleteTask, 17 + updateTaskStatus, 18 + updateTaskLabels, 19 + hideTask, 20 + } from "../api/tasks.ts"; 12 21 import { getComments, createComment, updateCommentApi, deleteComment } from "../api/comments.ts"; 13 22 import { getStatusHistory } from "../api/tasks.ts"; 14 23 import { getColumns, type KanbanColumnDef } from "../api/columns.ts"; ··· 384 393 const canChangeStatus = useCanDo("kanban", "changeStatus"); 385 394 const canManage = useCanDo("kanban", "manageTasks"); 386 395 396 + const availableLabels = useSignal<LabelData[]>([]); 397 + const localLabelIds = useSignal<string[] | null>(null); 398 + 399 + useEffect(() => { 400 + if (canManage.value) { 401 + getLabels() 402 + .then((res) => (availableLabels.value = res.labels)) 403 + .catch(() => {}); 404 + } 405 + }, [canManage.value]); 406 + 387 407 const task = data?.task; 388 408 const isAuthor = currentDid === task?.authorDid; 389 409 const canEdit = isAuthor || canManage.value; ··· 441 461 console.error("Failed to update task:", err); 442 462 } finally { 443 463 saving.value = false; 464 + } 465 + }; 466 + 467 + const handleLabelToggle = async (labelId: string, selected: boolean) => { 468 + if (!task) return; 469 + const label = availableLabels.value.find((l) => l.id === labelId); 470 + if (!label) return; 471 + const prev = task.labels; 472 + const prevIds = prev.map((l) => l.id); 473 + const nextIds = selected ? [...prevIds, labelId] : prevIds.filter((id) => id !== labelId); 474 + const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); 475 + task.labels = nextLabels; 476 + localLabelIds.value = nextIds; 477 + try { 478 + await updateTaskLabels(task.id, nextIds); 479 + } catch { 480 + task.labels = prev; 481 + localLabelIds.value = prev.map((l) => l.id); 444 482 } 445 483 }; 446 484 ··· 563 601 ))} 564 602 </select> 565 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 + /> 566 612 )} 567 613 </div> 568 614