diff --git a/src/components/WorkspaceHeader.tsx b/src/components/WorkspaceHeader.tsx new file mode 100644 index 0000000..1935613 --- /dev/null +++ b/src/components/WorkspaceHeader.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { GlassyCard } from "@/components/GlassyCard"; +import { Button } from "@/components/primitives/button"; + +type WorkspaceHeaderProps = { + actions?: ReactNode; + details?: string[]; + markers?: ReactNode; + meta?: ReactNode; + summary: ReactNode; + title: ReactNode; +}; + +type WorkspaceHeaderActionProps = { + children: ReactNode; + disabled?: boolean; + onClick: () => void; +}; + +export function WorkspaceHeader({ + actions, + details = [], + markers, + meta, + summary, + title, +}: WorkspaceHeaderProps) { + return ( + +
+
+

+ {title} +

+
{summary}
+
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ + {details.length > 0 ? ( +
+ {details.map((detail, index) => ( +

+ {detail} +

+ ))} +
+ ) : null} + + {markers || meta ? ( + + ) : null} +
+ ); +} + +export function WorkspaceHeaderAction({ + children, + disabled = false, + onClick, +}: WorkspaceHeaderActionProps) { + return ( + + ); +} diff --git a/src/pages/factions/hooks/useFactionActionRunner.ts b/src/hooks/useActionRunner.ts similarity index 51% rename from src/pages/factions/hooks/useFactionActionRunner.ts rename to src/hooks/useActionRunner.ts index 173e5b3..1bd6e2e 100644 --- a/src/pages/factions/hooks/useFactionActionRunner.ts +++ b/src/hooks/useActionRunner.ts @@ -1,34 +1,32 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { getApiErrorPayload } from "@/lib/apiClient"; -type UseFactionActionRunnerInput = { +type UseActionRunnerInput = { reload: () => Promise; }; -export function useFactionActionRunner({ - reload, -}: UseFactionActionRunnerInput) { +export function useActionRunner({ reload }: UseActionRunnerInput) { const [actionError, setActionError] = useState(null); const [mutating, setMutating] = useState(false); - - const setCommandError = (error: unknown) => { - const payload = getApiErrorPayload(error); - const message = - payload?.error?.message ?? - (error instanceof Error ? error.message : "Action failed"); - setActionError(message); - }; + const actionInFlight = useRef(false); const runAction = async (fn: () => Promise) => { + if (actionInFlight.current) return; + actionInFlight.current = true; setActionError(null); setMutating(true); try { await fn(); await reload(); } catch (error) { - setCommandError(error); + const payload = getApiErrorPayload(error); + setActionError( + payload?.error?.message ?? + (error instanceof Error ? error.message : "Action failed"), + ); } finally { + actionInFlight.current = false; setMutating(false); } }; diff --git a/src/lib/api/initiatives.ts b/src/lib/api/initiatives.ts index 138a8a4..670d9d4 100644 --- a/src/lib/api/initiatives.ts +++ b/src/lib/api/initiatives.ts @@ -5,6 +5,7 @@ import type { InitiativeRoleDto, InitiativeStatusDto, InitiativeThreadStatusDto, + InitiativeVisibilityDto, } from "@/types/api"; import { apiCommand } from "./command"; import { apiGet } from "./http"; @@ -23,6 +24,7 @@ export async function apiInitiativeCreate(input: { title: string; summary: string; description?: string; + visibility?: InitiativeVisibilityDto; tags?: string[]; idempotencyKey?: string; }): Promise<{ @@ -38,6 +40,7 @@ export async function apiInitiativeCreate(input: { ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), }, idempotencyKey: input.idempotencyKey, @@ -49,6 +52,7 @@ export async function apiInitiativeUpdate(input: { title?: string; summary?: string; description?: string; + visibility?: InitiativeVisibilityDto; status?: InitiativeStatusDto; tags?: string[]; idempotencyKey?: string; @@ -67,6 +71,7 @@ export async function apiInitiativeUpdate(input: { ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), ...(input.status ? { status: input.status } : {}), ...(input.tags ? { tags: input.tags } : {}), }, @@ -74,6 +79,81 @@ export async function apiInitiativeUpdate(input: { }); } +export async function apiInitiativeJoin(input: { + initiativeId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join"; + initiativeId: string; + joined: boolean; + pending: boolean; +}> { + return await apiCommand({ + type: "initiative.join", + payload: { initiativeId: input.initiativeId }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeJoinRequestApprove(input: { + initiativeId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join.request.approve"; + initiativeId: string; + address: string; + accepted: boolean; +}> { + return await apiCommand({ + type: "initiative.join.request.approve", + payload: { + initiativeId: input.initiativeId, + address: input.address, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeJoinRequestDecline(input: { + initiativeId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.join.request.decline"; + initiativeId: string; + address: string; + accepted: boolean; +}> { + return await apiCommand({ + type: "initiative.join.request.decline", + payload: { + initiativeId: input.initiativeId, + address: input.address, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiInitiativeLeave(input: { + initiativeId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "initiative.leave"; + initiativeId: string; + left: boolean; +}> { + return await apiCommand({ + type: "initiative.leave", + payload: { initiativeId: input.initiativeId }, + idempotencyKey: input.idempotencyKey, + }); +} + export async function apiInitiativeMemberRoleSet(input: { initiativeId: string; address: string; diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index dcb95e4..a51e459 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -17,6 +17,20 @@ type InitiativeManageability = { viewerCanSteward?: boolean; }; +type InitiativeAuthState = { + enabled: boolean; + loading: boolean; + authenticated: boolean; + eligible: boolean; +}; + +type InitiativeViewerState = InitiativeManageability & { + viewerRole?: InitiativeRoleDto | null; + viewerCanAdmin?: boolean; + viewerCanJoin?: boolean; + viewerCanLeave?: boolean; +}; + export const initiativeRoleLabel: Record = { admin: "Admin", steward: "Steward", @@ -98,6 +112,23 @@ export function canManageInitiative( return initiative.status === "active" && Boolean(initiative.viewerCanSteward); } +export function getInitiativeViewerCapabilities( + initiative: InitiativeViewerState, + auth: InitiativeAuthState, +) { + const canAct = + !auth.enabled || (auth.authenticated && auth.eligible && !auth.loading); + const isOperational = initiative.status === "active"; + + return { + canAdmin: canAct && Boolean(initiative.viewerCanAdmin), + canJoin: canAct && Boolean(initiative.viewerCanJoin), + canLeave: canAct && Boolean(initiative.viewerCanLeave), + canManage: canAct && canManageInitiative(initiative), + canParticipate: canAct && isOperational && initiative.viewerRole != null, + }; +} + export function initiativeSummaryPreview(value: string, maxLength = 150) { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) return normalized; @@ -106,6 +137,27 @@ export function initiativeSummaryPreview(value: string, maxLength = 150) { return normalized.slice(0, cutAt).trim(); } +export function initiativeDistinctDescription( + summary: string, + description: string, +) { + const normalizedSummary = summary.replace(/\s+/g, " ").trim(); + const normalizedDescription = description.replace(/\s+/g, " ").trim(); + + if (!normalizedDescription || normalizedDescription === normalizedSummary) { + return ""; + } + + return description.trim(); +} + +export function initiativeDescriptionParagraphs(description: string) { + return description + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim()) + .filter(Boolean); +} + export function initiativeOptionsWithSelection( options: Array<{ value: string; label: string }>, selectedId?: string, diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx index b74b456..b012b05 100644 --- a/src/pages/factions/Faction.tsx +++ b/src/pages/factions/Faction.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/apiClient"; import { formatLoadError } from "@/lib/errorFormatting"; import { getFactionViewerPermissions } from "@/lib/factionUi"; +import { useActionRunner } from "@/hooks/useActionRunner"; import { FactionChannelsSection } from "./components/FactionChannelsSection"; import { FactionEditCard } from "./components/FactionEditCard"; import { FactionHero } from "./components/FactionHero"; @@ -24,7 +25,6 @@ import { FactionInitiativesSection } from "./components/FactionInitiativesSectio import { FactionMembersSection } from "./components/FactionMembersSection"; import { FactionModerationQueues } from "./components/FactionModerationQueues"; import { useFactionChannelDraft } from "./hooks/useFactionChannelDraft"; -import { useFactionActionRunner } from "./hooks/useFactionActionRunner"; import { useFactionEditForm } from "./hooks/useFactionEditForm"; import { useFactionLegacyThreadRedirect } from "./hooks/useFactionLegacyThreadRedirect"; import { useFactionPageData } from "./hooks/useFactionPageData"; @@ -41,7 +41,7 @@ const Faction: React.FC = () => { const channelDraft = useFactionChannelDraft(); const editForm = useFactionEditForm(faction); - const { actionError, mutating, runAction } = useFactionActionRunner({ + const { actionError, mutating, runAction } = useActionRunner({ reload: reloadFaction, }); diff --git a/src/pages/factions/components/FactionHero.tsx b/src/pages/factions/components/FactionHero.tsx index be32dc3..e7385ad 100644 --- a/src/pages/factions/components/FactionHero.tsx +++ b/src/pages/factions/components/FactionHero.tsx @@ -1,9 +1,11 @@ -import { Kicker } from "@/components/Kicker"; -import { StatTile } from "@/components/StatTile"; +import { Chip } from "@/components/Chip"; +import { GlassyStatusChip } from "@/components/GlassySection"; import { HintLabel } from "@/components/Hint"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Card, CardContent } from "@/components/primitives/card"; +import { MetricTile } from "@/components/MetricTile"; +import { + WorkspaceHeader, + WorkspaceHeaderAction, +} from "@/components/WorkspaceHeader"; import type { FactionDto } from "@/types/api"; type FactionHeroProps = { @@ -35,25 +37,22 @@ export const FactionHero: React.FC = ({ viewerMembershipActive, viewerRole, }) => { + const roleLabel = viewerRole + ? viewerRole.charAt(0).toUpperCase() + viewerRole.slice(1) + : "Observer"; + const pendingJoin = + viewerJoinRequest?.status === "pending" && !viewerMembershipActive; + return ( <> - - -
-
- {faction.focus} -

- {faction.name} -

-

- {faction.description} -

-
- -
+ {canJoin ? ( - + ) : null} {canLeave ? ( - + ) : null} {isFounderAdmin ? ( - + ) : null} -
-
- - {viewerRole === "founder" ? ( -
- - Founder leave disabled until transfer - -
- ) : null} - {viewerJoinRequest?.status === "pending" && - !viewerMembershipActive ? ( -
- - Private faction join request pending - -
- ) : null} -
-
+ + ) : undefined + } + markers={ + <> + + {faction.visibility === "public" ? "Public" : "Private"} + + {roleLabel} + + {faction.focus} + + + } + meta={ + viewerRole === "founder" || pendingJoin ? ( + <> + {viewerRole === "founder" ? ( + + Founder leave requires transfer + + ) : null} + {pendingJoin ? ( + + Join request pending + + ) : null} + + ) : undefined + } + /> -
- - + + } value={String(faction.acm)} - align="center" /> - +
diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 358ebca..90fc0b1 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,18 +1,31 @@ -import { useMemo } from "react"; -import { Link, useParams } from "react-router"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useAuth } from "@/app/auth/AuthContext"; 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 { + WorkspaceHeader, + WorkspaceHeaderAction, +} from "@/components/WorkspaceHeader"; +import { useActionRunner } from "@/hooks/useActionRunner"; +import { + apiInitiativeJoin, + apiInitiativeJoinRequestApprove, + apiInitiativeJoinRequestDecline, + apiInitiativeLeave, +} from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { - canManageInitiative, defaultInitiativeBoardColumns, + getInitiativeViewerCapabilities, + initiativeBoardCardCreatePath, + initiativeDescriptionParagraphs, + initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, initiativeStatusTone, @@ -20,6 +33,7 @@ import { import { shortAddress } from "@/lib/profileUi"; import { InitiativeBoardSection } from "./components/InitiativeBoardSection"; import { InitiativeChatSection } from "./components/InitiativeChatSection"; +import { InitiativeJoinRequestsSection } from "./components/InitiativeJoinRequestsSection"; import { InitiativeMembersSection } from "./components/InitiativeMembersSection"; import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection"; import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; @@ -28,7 +42,11 @@ import { useInitiativePageData } from "./hooks/useInitiativePageData"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const auth = useAuth(); const { initiative, loadError, reload } = useInitiativePageData(id); + const [editing, setEditing] = useState(false); + const { actionError, mutating, runAction } = useActionRunner({ reload }); const boardColumns = useMemo(() => { if (!initiative) return []; @@ -64,47 +82,118 @@ const Initiative: React.FC = () => { const proposals = initiative.proposals ?? []; const chatMessages = initiative.chatMessages ?? []; const isOperational = initiative.status === "active"; - const canManage = canManageInitiative(initiative); - const canAdmin = Boolean(initiative.viewerCanAdmin); - const canParticipate = isOperational && initiative.viewerRole != null; + const { + canAdmin, + canJoin: viewerCanJoin, + canLeave: viewerCanLeave, + canManage, + canParticipate, + } = getInitiativeViewerCapabilities(initiative, auth); + const pendingJoinRequest = initiative.viewerJoinRequest?.status === "pending"; const viewerRole = initiative.viewerRole ? initiativeRoleLabel[initiative.viewerRole] : "Observer"; + const description = initiativeDistinctDescription( + initiative.summary, + initiative.description, + ); + const descriptionParagraphs = initiativeDescriptionParagraphs(description); return (
- - - + summary={initiative.summary} + details={descriptionParagraphs} + actions={ + canAdmin || + canManage || + viewerCanJoin || + viewerCanLeave || + pendingJoinRequest ? ( + <> + {viewerCanJoin || pendingJoinRequest ? ( + + void runAction(async () => { + await apiInitiativeJoin({ initiativeId: initiative.id }); + }) + } + > + {pendingJoinRequest + ? "Request pending" + : initiative.visibility === "private" + ? "Request to join" + : "Join initiative"} + + ) : null} + {viewerCanLeave ? ( + + void runAction(async () => { + await apiInitiativeLeave({ initiativeId: initiative.id }); + }) + } + > + Leave initiative + + ) : null} + {canAdmin ? ( + setEditing(true)}> + Edit initiative + + ) : null} + {canManage ? ( + + navigate( + initiativeBoardCardCreatePath({ id: initiative.id }), + ) + } + > + Create card + + ) : null} + + ) : undefined + } + markers={ + <> {initiativeStatusLabel[initiative.status]} - {viewerRole} -
+ + {initiative.visibility === "public" ? "Public" : "Private"} + + {viewerRole} + {initiative.tags.length > 0 ? ( +