diff --git a/packages/client/package.json b/packages/client/package.json index f9521db..06f05a3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,6 +23,7 @@ "./components/theme-toggle": "./src/components/theme-toggle.tsx", "./components/label-badge": "./src/components/label-badge.tsx", "./components/label-picker": "./src/components/label-picker.tsx", + "./components/inline-label-editor": "./src/components/inline-label-editor.tsx", "./api/labels": "./src/api/labels.ts" }, "peerDependencies": { diff --git a/packages/client/src/components/inline-label-editor.css.ts b/packages/client/src/components/inline-label-editor.css.ts new file mode 100644 index 0000000..cf24fc2 --- /dev/null +++ b/packages/client/src/components/inline-label-editor.css.ts @@ -0,0 +1,57 @@ +import { style } from "@vanilla-extract/css"; +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", + background: "none", + border: "none", + color: vars.color.textMuted, + cursor: "pointer", + padding: vars.space.xs, + borderRadius: vars.radius.sm, + lineHeight: 1, + fontSize: "0.875rem", + transition: "color 0.15s", + ":hover": { + color: vars.color.text, + }, +}); + +export const dropdown = style({ + position: "absolute", + zIndex: 10, + insetBlockStart: "100%", + insetInlineEnd: 0, + marginBlockStart: vars.space.xs, + minInlineSize: "200px", + maxBlockSize: "240px", + 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, +}); + +export const option = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + paddingBlock: vars.space.xs, + paddingInline: vars.space.sm, + cursor: "pointer", + 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 new file mode 100644 index 0000000..2930a2e --- /dev/null +++ b/packages/client/src/components/inline-label-editor.tsx @@ -0,0 +1,76 @@ +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"; +export type { LabelOption }; + +export function InlineLabelEditor({ + labels, + selectedIds, + onToggle, +}: { + labels: LabelOption[]; + selectedIds: string[]; + onToggle: (labelId: string, selected: boolean) => void; +}) { + const open = useSignal(false); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!open.value) return; + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + open.value = false; + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open.value]); + + if (labels.length === 0) return null; + + const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); + + return ( +
+ Labels: + {selectedLabels.length > 0 ? ( + + {selectedLabels.map((l) => ( + + ))} + + ) : ( + None + )} + + + {open.value && ( +
+ {labels.map((label) => { + const isSelected = selectedIds.includes(label.id); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/packages/feature-requests/src/ui/pages/feature-request.tsx b/packages/feature-requests/src/ui/pages/feature-request.tsx index a798528..0132342 100644 --- a/packages/feature-requests/src/ui/pages/feature-request.tsx +++ b/packages/feature-requests/src/ui/pages/feature-request.tsx @@ -22,6 +22,7 @@ import { unvoteComment, getMyCommentVotes, updateFeatureRequestStatus, + updateFeatureRequestLabels, getStatusHistory, getDuplicates, settableStatuses, @@ -34,6 +35,8 @@ import { RequestCard } from "../components/request-card.tsx"; import { SortControls } from "../components/sort-controls.tsx"; import { useSortParams } from "../hooks/use-sort-params.ts"; import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; +import { InlineLabelEditor } from "@exosphere/client/components/inline-label-editor"; +import { getLabels, type LabelData } from "@exosphere/client/api/labels"; import { useEffect, useRef } from "preact/hooks"; import { formatDate } from "@exosphere/client/format"; @@ -563,6 +566,17 @@ export function FeatureRequestPage() { const canChangeStatus = useCanDo("feature-requests", "changeStatus"); const canMarkDuplicate = useCanDo("feature-requests", "markDuplicate"); + const availableLabels = useSignal([]); + const localLabelIds = useSignal(null); + + useEffect(() => { + if (canChangeStatus.value) { + getLabels() + .then((res) => (availableLabels.value = res.labels)) + .catch(() => {}); + } + }, [canChangeStatus.value]); + const fr = data?.featureRequest; const handleDelete = async () => { @@ -620,6 +634,25 @@ export function FeatureRequestPage() { } }; + const handleLabelToggle = async (labelId: string, selected: boolean) => { + if (!fr) 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; + try { + await updateFeatureRequestLabels(fr.id, nextIds); + } catch { + fr.labels = prev; + localLabelIds.value = prev.map((l) => l.id); + } + }; + return (
@@ -673,6 +706,14 @@ export function FeatureRequestPage() {
)} + {canChangeStatus.value && availableLabels.value.length > 0 && ( + l.id)} + onToggle={handleLabelToggle} + /> + )} + {data.duplicateOf && (

Duplicate of{" "} diff --git a/packages/kanban/src/ui/api/tasks.ts b/packages/kanban/src/ui/api/tasks.ts index b135f3c..e041167 100644 --- a/packages/kanban/src/ui/api/tasks.ts +++ b/packages/kanban/src/ui/api/tasks.ts @@ -79,6 +79,17 @@ export function unhideTask(id: string) { }); } +export function updateTaskLabels(id: string, labelIds: string[]) { + return moduleFetch<{ labels: Array<{ id: string; name: string; color: string }> }>( + `/kanban/${encodeURIComponent(id)}/labels`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ labelIds }), + }, + ); +} + export function getStatusHistory(taskId: string) { return moduleFetch<{ statuses: Array<{ diff --git a/packages/kanban/src/ui/pages/task.tsx b/packages/kanban/src/ui/pages/task.tsx index ec66553..3bb5719 100644 --- a/packages/kanban/src/ui/pages/task.tsx +++ b/packages/kanban/src/ui/pages/task.tsx @@ -6,9 +6,18 @@ import { useQuery } from "@exosphere/client/hooks"; 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 { getLabels, type LabelData } from "@exosphere/client/api/labels"; import * as ui from "@exosphere/client/ui.css"; import * as kbUi from "../ui.css.ts"; -import { getTask, updateTask, deleteTask, updateTaskStatus, hideTask } from "../api/tasks.ts"; +import { + getTask, + updateTask, + deleteTask, + updateTaskStatus, + updateTaskLabels, + hideTask, +} from "../api/tasks.ts"; import { getComments, createComment, updateCommentApi, deleteComment } from "../api/comments.ts"; import { getStatusHistory } from "../api/tasks.ts"; import { getColumns, type KanbanColumnDef } from "../api/columns.ts"; @@ -384,6 +393,17 @@ export function TaskPage() { const canChangeStatus = useCanDo("kanban", "changeStatus"); const canManage = useCanDo("kanban", "manageTasks"); + const availableLabels = useSignal([]); + const localLabelIds = useSignal(null); + + useEffect(() => { + if (canManage.value) { + getLabels() + .then((res) => (availableLabels.value = res.labels)) + .catch(() => {}); + } + }, [canManage.value]); + const task = data?.task; const isAuthor = currentDid === task?.authorDid; const canEdit = isAuthor || canManage.value; @@ -444,6 +464,24 @@ export function TaskPage() { } }; + const handleLabelToggle = async (labelId: string, selected: boolean) => { + if (!task) return; + const label = availableLabels.value.find((l) => l.id === labelId); + if (!label) return; + const prev = task.labels; + const prevIds = prev.map((l) => l.id); + const nextIds = selected ? [...prevIds, labelId] : prevIds.filter((id) => id !== labelId); + const nextLabels = selected ? [...prev, label] : prev.filter((l) => l.id !== labelId); + task.labels = nextLabels; + localLabelIds.value = nextIds; + try { + await updateTaskLabels(task.id, nextIds); + } catch { + task.labels = prev; + localLabelIds.value = prev.map((l) => l.id); + } + }; + return (

@@ -564,6 +602,14 @@ export function TaskPage() {
)} + + {canManage.value && !editing.value && availableLabels.value.length > 0 && ( + l.id)} + onToggle={handleLabelToggle} + /> + )}