Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -115,6 +116,10 @@ const AppRoutes: React.FC = () => {
<Route path="formation" element={<Formation />} />
<Route path="initiatives" element={<Initiatives />} />
<Route path="initiatives/new" element={<InitiativeCreate />} />
<Route
path="initiatives/:id/board/new"
element={<InitiativeBoardCardCreate />}
/>
<Route path="initiatives/:id" element={<Initiative />} />
<Route path="invision" element={<Invision />} />
<Route path="vortexopedia" element={<Vortexopedia />} />
Expand Down
7 changes: 6 additions & 1 deletion src/components/GlassySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Surface } from "@/components/Surface";
import "./GlassySection.css";

type GlassySectionProps = {
action?: ReactNode;
children: ReactNode;
className?: string;
title: ReactNode;
Expand Down Expand Up @@ -63,13 +64,17 @@ type GlassyProgressBarProps = {
};

export function GlassySection({
action,
children,
className,
title,
}: GlassySectionProps) {
return (
<section className={cn("glassy-section", className)}>
<SectionHeader className="glassy-section__title">{title}</SectionHeader>
<div className="flex items-center justify-between gap-3">
<SectionHeader className="glassy-section__title">{title}</SectionHeader>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/ProposalPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,7 +79,7 @@ export function ProposalPageHeader({
<h1 className="text-center text-2xl font-semibold text-text">{title}</h1>
{status?.initiative ? (
<div className="flex justify-center">
<Link to={`/app/initiatives/${status.initiative.id}`}>
<Link to={initiativePath(status.initiative)}>
<Chip className="stage-chip stage-chip--system">
Initiative · {status.initiative.title}
</Chip>
Expand Down
20 changes: 20 additions & 0 deletions src/lib/api/initiatives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 44 additions & 1 deletion src/lib/initiativeUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InitiativeRoleDto, string> = {
admin: "Admin",
steward: "Steward",
Expand Down Expand Up @@ -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" {
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
137 changes: 42 additions & 95 deletions src/pages/initiatives/Initiative.tsx
Original file line number Diff line number Diff line change
@@ -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<InitiativeDto | null>(null);
const [loadError, setLoadError] = useState<string | null>(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<InitiativeBoardColumnDto>((status, index) => ({
id: status,
key: status,
title: initiativeBoardStatusLabel[status],
sortOrder: index,
}));
return defaultInitiativeBoardColumns;
}, [initiative]);

if (!id) {
Expand Down Expand Up @@ -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
Expand All @@ -97,107 +73,78 @@ const Initiative: React.FC = () => {

return (
<div className="flex flex-col gap-6">
<div className="flex justify-start">
<Button asChild variant="ghost" size="sm">
<Link to="/app/initiatives">Back to initiatives</Link>
</Button>
</div>

<GlassyCard as="article" className="p-5">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<GlassyStatusChip tone={initiativeStatusTone(initiative.status)}>
{initiativeStatusLabel[initiative.status]}
</GlassyStatusChip>
<Chip className="stage-chip stage-chip--system">
{viewerRole}
</Chip>
</div>
<h1 className="text-2xl leading-tight font-semibold text-text">
{initiative.title}
</h1>
<p className="max-w-4xl text-sm leading-relaxed text-muted">
{initiative.description || initiative.summary}
</p>
<Button asChild variant="ghost" size="sm" className="w-fit">
<Link to="/app/initiatives">Back to initiatives</Link>
</Button>

<PageHeader
title={initiative.title}
titleClassName="text-2xl"
description={initiative.description || initiative.summary}
descriptionClassName="max-w-4xl leading-relaxed"
right={
<div className="flex flex-wrap items-center justify-end gap-2">
<GlassyStatusChip tone={initiativeStatusTone(initiative.status)}>
{initiativeStatusLabel[initiative.status]}
</GlassyStatusChip>
<Chip className="stage-chip stage-chip--system">{viewerRole}</Chip>
</div>
}
/>

<dl className="grid min-w-64 grid-cols-2 gap-2 text-sm">
<HeroMetric label="Members" value={initiative.memberCount} />
<HeroMetric label="Board" value={cards.length} />
<HeroMetric label="Threads" value={threads.length} />
<HeroMetric label="Proposals" value={proposals.length} />
</dl>
</div>

<div className="mt-5 flex flex-wrap gap-2">
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
<div className="flex flex-wrap gap-2">
{initiative.tags.map((tag) => (
<Chip key={tag} className="stage-chip stage-chip--system">
{tag}
</Chip>
))}
</div>

<div className="mt-5 flex flex-wrap justify-between gap-3 border-t border-[color:var(--surface-glass-border)] pt-4 text-xs text-muted">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>Created by {shortAddress(initiative.createdByAddress)}</span>
<span>Updated {formatDateTime(initiative.updatedAt)}</span>
</div>
</GlassyCard>
</div>

<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricTile label="Members" value={initiative.memberCount} />
<MetricTile label="Board" value={cards.length} />
<MetricTile label="Threads" value={threads.length} />
<MetricTile label="Proposals" value={proposals.length} />
</section>

{canAdmin ? (
<InitiativeSettingsSection
initiative={initiative}
onChanged={loadInitiative}
/>
<InitiativeSettingsSection initiative={initiative} onChanged={reload} />
) : null}
<InitiativeBoardSection
cards={cards}
canManage={canManage}
columns={boardColumns}
initiativeId={initiative.id}
onChanged={loadInitiative}
onChanged={reload}
/>
<InitiativeThreadsSection
canModerate={canManage}
canPost={canParticipate}
initiativeId={initiative.id}
onChanged={loadInitiative}
onChanged={reload}
threads={threads}
/>
<InitiativeChatSection
canPost={canParticipate}
initiativeId={initiative.id}
messages={chatMessages}
onChanged={loadInitiative}
onChanged={reload}
/>
<InitiativeProposalsSection proposals={proposals} />
<InitiativeMembersSection
canAdmin={isOperational && canAdmin}
initiativeId={initiative.id}
memberships={memberships}
onChanged={loadInitiative}
onChanged={reload}
/>
</div>
);
};

function HeroMetric({
label,
value,
}: {
label: string;
value: number | string;
}) {
return (
<div className="rounded-xl border border-[color:var(--surface-glass-border)] bg-[color:var(--surface-glass-bg)] px-3 py-2 text-center">
<dt className="text-[0.68rem] leading-none font-semibold tracking-wide text-muted uppercase">
{label}
</dt>
<dd className="mt-1 text-base leading-none font-semibold text-text">
{value}
</dd>
</div>
);
}

export default Initiative;
Loading
Loading