Skip to content
91 changes: 91 additions & 0 deletions src/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GlassyCard as="article" className="p-5 sm:p-6">
<header className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
<div className="max-w-4xl min-w-0 space-y-2">
<h1 className="text-2xl leading-tight font-semibold text-text sm:text-3xl">
{title}
</h1>
<div className="text-base leading-relaxed text-muted">{summary}</div>
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{actions}
</div>
) : null}
</header>

{details.length > 0 ? (
<div className="mt-5 max-w-4xl space-y-3 border-t border-[color:var(--surface-glass-border)] pt-5">
{details.map((detail, index) => (
<p
key={`${index}-${detail}`}
className="border-l-2 border-[color:var(--surface-glass-border)] pl-3 text-sm leading-7 text-text"
>
{detail}
</p>
))}
</div>
) : null}

{markers || meta ? (
<footer className="mt-5 flex flex-col gap-4 border-t border-[color:var(--surface-glass-border)] pt-4 lg:flex-row lg:items-center lg:justify-between">
{markers ? (
<div className="flex flex-wrap items-center gap-2">{markers}</div>
) : null}
{meta ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
{meta}
</div>
) : null}
</footer>
) : null}
</GlassyCard>
);
}

export function WorkspaceHeaderAction({
children,
disabled = false,
onClick,
}: WorkspaceHeaderActionProps) {
return (
<Button
type="button"
size="sm"
variant="outline"
className="w-36 text-sm font-semibold opacity-90 hover:opacity-100"
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import { useState } from "react";
import { useRef, useState } from "react";

import { getApiErrorPayload } from "@/lib/apiClient";

type UseFactionActionRunnerInput = {
type UseActionRunnerInput = {
reload: () => Promise<void>;
};

export function useFactionActionRunner({
reload,
}: UseFactionActionRunnerInput) {
export function useActionRunner({ reload }: UseActionRunnerInput) {
const [actionError, setActionError] = useState<string | null>(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<void>) => {
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);
}
};
Expand Down
80 changes: 80 additions & 0 deletions src/lib/api/initiatives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
InitiativeRoleDto,
InitiativeStatusDto,
InitiativeThreadStatusDto,
InitiativeVisibilityDto,
} from "@/types/api";
import { apiCommand } from "./command";
import { apiGet } from "./http";
Expand All @@ -23,6 +24,7 @@ export async function apiInitiativeCreate(input: {
title: string;
summary: string;
description?: string;
visibility?: InitiativeVisibilityDto;
tags?: string[];
idempotencyKey?: string;
}): Promise<{
Expand All @@ -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,
Expand All @@ -49,6 +52,7 @@ export async function apiInitiativeUpdate(input: {
title?: string;
summary?: string;
description?: string;
visibility?: InitiativeVisibilityDto;
status?: InitiativeStatusDto;
tags?: string[];
idempotencyKey?: string;
Expand All @@ -67,13 +71,89 @@ 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 } : {}),
},
idempotencyKey: input.idempotencyKey,
});
}

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;
Expand Down
52 changes: 52 additions & 0 deletions src/lib/initiativeUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InitiativeRoleDto, string> = {
admin: "Admin",
steward: "Steward",
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/pages/factions/Faction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ 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";
import { FactionInitiativesSection } from "./components/FactionInitiativesSection";
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";
Expand All @@ -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,
});

Expand Down
Loading
Loading