diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index c35d0cb..bb32730 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -38,6 +38,7 @@ import Guide from "../pages/Guide"; import Settings from "../pages/settings/Settings"; import Initiatives from "../pages/initiatives/Initiatives"; import Initiative from "../pages/initiatives/Initiative"; +import InitiativeBoardCardCreate from "../pages/initiatives/InitiativeBoardCardCreate"; import InitiativeCreate from "../pages/initiatives/InitiativeCreate"; const AppRoutes: React.FC = () => { @@ -115,6 +116,10 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/src/components/GlassySection.tsx b/src/components/GlassySection.tsx index 11eb90a..14429e3 100644 --- a/src/components/GlassySection.tsx +++ b/src/components/GlassySection.tsx @@ -6,6 +6,7 @@ import { Surface } from "@/components/Surface"; import "./GlassySection.css"; type GlassySectionProps = { + action?: ReactNode; children: ReactNode; className?: string; title: ReactNode; @@ -63,13 +64,17 @@ type GlassyProgressBarProps = { }; export function GlassySection({ + action, children, className, title, }: GlassySectionProps) { return (
- {title} +
+ {title} + {action ?
{action}
: null} +
{children}
); diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx index 3833adc..89fc078 100644 --- a/src/components/ProposalPageHeader.tsx +++ b/src/components/ProposalPageHeader.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router"; import { Chip } from "@/components/Chip"; +import { initiativePath } from "@/lib/initiativeUi"; import { buildProposalStageLinks, ProposalStageBar, @@ -78,7 +79,7 @@ export function ProposalPageHeader({

{title}

{status?.initiative ? (
- + Initiative ยท {status.initiative.title} diff --git a/src/lib/api/initiatives.ts b/src/lib/api/initiatives.ts index de05646..138a8a4 100644 --- a/src/lib/api/initiatives.ts +++ b/src/lib/api/initiatives.ts @@ -152,6 +152,26 @@ export async function apiInitiativeBoardCardUpdate(input: { }); } +export async function apiInitiativeBoardCardDelete(input: { + initiativeId: string; + cardId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.board.card.delete"; + initiativeId: string; + cardId: string; +}> { + return await apiCommand({ + type: "initiative.board.card.delete", + payload: { + initiativeId: input.initiativeId, + cardId: input.cardId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + export async function apiInitiativeThreadCreate(input: { initiativeId: string; title: string; diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index 231bcd4..dcb95e4 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -7,6 +7,16 @@ import type { InitiativeThreadStatusDto, } from "@/types/api"; +type InitiativePathIdentity = { + id: string; + slug?: string; +}; + +type InitiativeManageability = { + status: InitiativeStatusDto; + viewerCanSteward?: boolean; +}; + export const initiativeRoleLabel: Record = { admin: "Admin", steward: "Steward", @@ -39,6 +49,22 @@ export const initiativeBoardStatusLabel: Record< done: "Done", }; +export const initiativeBoardStatusOrder: InitiativeBoardCardStatusDto[] = [ + "backlog", + "doing", + "proposal", + "blocked", + "done", +]; + +export const defaultInitiativeBoardColumns: InitiativeBoardColumnDto[] = + initiativeBoardStatusOrder.map((status, sortOrder) => ({ + id: status, + key: status, + title: initiativeBoardStatusLabel[status], + sortOrder, + })); + export function initiativeStatusTone( status: InitiativeStatusDto, ): "neutral" | "ok" | "warn" { @@ -55,6 +81,23 @@ export function initiativeThreadStatusTone( return "neutral"; } +export function initiativePath(initiative: InitiativePathIdentity): string { + const routeId = initiative.slug?.trim() || initiative.id; + return `/app/initiatives/${encodeURIComponent(routeId)}`; +} + +export function initiativeBoardCardCreatePath( + initiative: InitiativePathIdentity, +): string { + return `${initiativePath(initiative)}/board/new`; +} + +export function canManageInitiative( + initiative: InitiativeManageability, +): boolean { + return initiative.status === "active" && Boolean(initiative.viewerCanSteward); +} + export function initiativeSummaryPreview(value: string, maxLength = 150) { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) return normalized; @@ -87,7 +130,7 @@ export function initiativeCardsForColumn( (card) => card.columnId === column.id || card.columnId === column.key || - (!card.columnId && card.status === column.key), + card.status === column.key, ); return Array.from( diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 6cc9578..358ebca 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,65 +1,41 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { Link, useParams } from "react-router"; import { Chip } from "@/components/Chip"; import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; +import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/primitives/button"; -import { apiInitiative } from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { - initiativeBoardStatusLabel, + canManageInitiative, + defaultInitiativeBoardColumns, initiativeRoleLabel, initiativeStatusLabel, initiativeStatusTone, } from "@/lib/initiativeUi"; import { shortAddress } from "@/lib/profileUi"; -import type { InitiativeBoardColumnDto, InitiativeDto } from "@/types/api"; import { InitiativeBoardSection } from "./components/InitiativeBoardSection"; import { InitiativeChatSection } from "./components/InitiativeChatSection"; import { InitiativeMembersSection } from "./components/InitiativeMembersSection"; import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection"; import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; +import { useInitiativePageData } from "./hooks/useInitiativePageData"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); - const [initiative, setInitiative] = useState(null); - const [loadError, setLoadError] = useState(null); - - const loadInitiative = useCallback(async () => { - if (!id) return; - try { - const res = await apiInitiative(id); - setInitiative(res); - setLoadError(null); - } catch (error) { - setInitiative(null); - setLoadError((error as Error).message); - } - }, [id]); - - useEffect(() => { - void loadInitiative(); - }, [loadInitiative]); + const { initiative, loadError, reload } = useInitiativePageData(id); const boardColumns = useMemo(() => { if (!initiative) return []; if (initiative.boardColumns && initiative.boardColumns.length > 0) { return initiative.boardColumns; } - - const statuses = Array.from( - new Set((initiative.boardCards ?? []).map((card) => card.status)), - ); - return statuses.map((status, index) => ({ - id: status, - key: status, - title: initiativeBoardStatusLabel[status], - sortOrder: index, - })); + return defaultInitiativeBoardColumns; }, [initiative]); if (!id) { @@ -88,7 +64,7 @@ const Initiative: React.FC = () => { const proposals = initiative.proposals ?? []; const chatMessages = initiative.chatMessages ?? []; const isOperational = initiative.status === "active"; - const canManage = isOperational && Boolean(initiative.viewerCanSteward); + const canManage = canManageInitiative(initiative); const canAdmin = Boolean(initiative.viewerCanAdmin); const canParticipate = isOperational && initiative.viewerRole != null; const viewerRole = initiative.viewerRole @@ -97,107 +73,78 @@ const Initiative: React.FC = () => { return (
-
- -
- - -
-
-
- - {initiativeStatusLabel[initiative.status]} - - - {viewerRole} - -
-

- {initiative.title} -

-

- {initiative.description || initiative.summary} -

+ + + + + {initiativeStatusLabel[initiative.status]} + + {viewerRole}
+ } + /> -
- - - - -
-
- -
+
+
{initiative.tags.map((tag) => ( {tag} ))}
- -
+
Created by {shortAddress(initiative.createdByAddress)} Updated {formatDateTime(initiative.updatedAt)}
- +
+ +
+ + + + +
{canAdmin ? ( - + ) : null}
); }; -function HeroMetric({ - label, - value, -}: { - label: string; - value: number | string; -}) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} - export default Initiative; diff --git a/src/pages/initiatives/InitiativeBoardCardCreate.tsx b/src/pages/initiatives/InitiativeBoardCardCreate.tsx new file mode 100644 index 0000000..5ae2e46 --- /dev/null +++ b/src/pages/initiatives/InitiativeBoardCardCreate.tsx @@ -0,0 +1,143 @@ +import type { FormEvent } from "react"; +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router"; + +import { GlassyCard } from "@/components/GlassyCard"; +import { GlassySection } from "@/components/GlassySection"; +import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { PageHeader } from "@/components/PageHeader"; +import { Textarea } from "@/components/Textarea"; +import { Button } from "@/components/primitives/button"; +import { Input } from "@/components/primitives/input"; +import { apiInitiativeBoardCardCreate } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import { canManageInitiative, initiativePath } from "@/lib/initiativeUi"; +import { useInitiativePageData } from "./hooks/useInitiativePageData"; + +const InitiativeBoardCardCreate: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { initiative, loadError } = useInitiativePageData(id); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + async function submitCard(event: FormEvent) { + event.preventDefault(); + if (!initiative || !title.trim()) return; + setSubmitting(true); + setSubmitError(null); + try { + await apiInitiativeBoardCardCreate({ + initiativeId: initiative.id, + title: title.trim(), + body: body.trim(), + }); + navigate(initiativePath(initiative)); + } catch (error) { + setSubmitError((error as Error).message); + } finally { + setSubmitting(false); + } + } + + if (!id) { + return ; + } + + if (loadError) { + return ( + + Initiative unavailable: {formatLoadError(loadError)} + + ); + } + + if (!initiative) { + return ( + + Loading initiative... + + ); + } + + const backHref = initiativePath(initiative); + const canCreate = canManageInitiative(initiative); + + return ( +
+ + + + + {!canCreate ? ( + + ) : ( + +
+
+ + setTitle(event.target.value)} + placeholder="Work item title" + required + maxLength={160} + /> +
+ +
+ +