diff --git a/apps/web/src/app/[lang]/dho/[id]/_components/select-settings-action.tsx b/apps/web/src/app/[lang]/dho/[id]/_components/select-settings-action.tsx index 0abd9f8e9..7cea6f63e 100644 --- a/apps/web/src/app/[lang]/dho/[id]/_components/select-settings-action.tsx +++ b/apps/web/src/app/[lang]/dho/[id]/_components/select-settings-action.tsx @@ -4,7 +4,6 @@ import { SelectAction, useActionGating } from '@hypha-platform/epics'; import { Locale } from '@hypha-platform/i18n'; import { isAbsoluteUrl } from '@hypha-platform/ui-utils'; import { - ArchiveIcon, ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, @@ -68,16 +67,6 @@ export const SelectSettingsAction = ({ baseTab: 'agreements', icon: , }, - { - group: 'Overview', - title: 'Archive Space (Coming Soon)', - description: - 'Archive this space to disable activity while preserving its data and history.', - href: '#', - icon: , - baseTab: 'members', - disabled: true, - }, { defaultDurationDays: 4, group: 'Agreements', diff --git a/apps/web/src/app/[lang]/dho/[id]/layout.tsx b/apps/web/src/app/[lang]/dho/[id]/layout.tsx index 9eba52a44..79aa7e5a3 100644 --- a/apps/web/src/app/[lang]/dho/[id]/layout.tsx +++ b/apps/web/src/app/[lang]/dho/[id]/layout.tsx @@ -27,6 +27,7 @@ import { DEFAULT_SPACE_LEAD_IMAGE, fetchSpaceDetails, fetchSpaceProposalsIds, + isSpaceArchived, } from '@hypha-platform/core/client'; import { notFound } from 'next/navigation'; import { db } from '@hypha-platform/storage-postgres'; @@ -165,6 +166,9 @@ export default async function DhoLayout({ web3SpaceId={spaceFromDb.web3SpaceId as number} isSandbox={spaceFromDb.flags.includes('sandbox')} isDemo={spaceFromDb.flags.includes('demo')} + isArchived={ + spaceFromDb.flags.includes('archived') || spaceMembers === 0 + } configPath={`${getDhoPathAgreements( lang, daoSlug, @@ -202,6 +206,7 @@ export default async function DhoLayout({ title={space.title as string} isSandbox={space.flags?.includes('sandbox') ?? false} isDemo={space.flags?.includes('demo') ?? false} + isArchived={isSpaceArchived(space)} web3SpaceId={space.web3SpaceId as number} configPath={`${getDhoPathAgreements( lang, diff --git a/apps/web/src/app/[lang]/my-spaces/page.tsx b/apps/web/src/app/[lang]/my-spaces/page.tsx index b124e0d78..0205185e6 100644 --- a/apps/web/src/app/[lang]/my-spaces/page.tsx +++ b/apps/web/src/app/[lang]/my-spaces/page.tsx @@ -4,6 +4,7 @@ import { SpaceSearch, AuthenticatedLinkButton, } from '@hypha-platform/epics'; +import { isSpaceArchived } from '@hypha-platform/core/client'; import Link from 'next/link'; import { Locale } from '@hypha-platform/i18n'; import { @@ -91,6 +92,7 @@ export default async function Index(props: PageProps) { title={space.title as string} isSandbox={space.flags?.includes('sandbox') ?? false} isDemo={space.flags?.includes('demo') ?? false} + isArchived={isSpaceArchived(space)} web3SpaceId={space.web3SpaceId as number} createdAt={space.createdAt} configPath={`${getDhoPathAgreements( diff --git a/apps/web/src/app/[lang]/network/page.tsx b/apps/web/src/app/[lang]/network/page.tsx index aa72b91cc..a0a0265fd 100644 --- a/apps/web/src/app/[lang]/network/page.tsx +++ b/apps/web/src/app/[lang]/network/page.tsx @@ -52,6 +52,7 @@ export default async function Index(props: PageProps) { const spaces = await getAllSpaces({ search: query?.trim() || undefined, parentOnly: false, + omitArchived: true, }); const uniqueCategories = extractUniqueCategories(spaces); diff --git a/packages/core/src/categories/types.ts b/packages/core/src/categories/types.ts index f07bf6683..84627df8b 100644 --- a/packages/core/src/categories/types.ts +++ b/packages/core/src/categories/types.ts @@ -49,5 +49,5 @@ export type SpaceOrder = (typeof SPACE_ORDERS)[number]; /** * @todo fix duplication. Origin at `packages/storage-postgres/src/schema/flags.ts` */ -export const SPACE_FLAGS = ['sandbox', 'demo'] as const; +export const SPACE_FLAGS = ['sandbox', 'demo', 'archived'] as const; export type SpaceFlags = (typeof SPACE_FLAGS)[number]; diff --git a/packages/core/src/space/index.ts b/packages/core/src/space/index.ts index 9c24fd80c..a9ace402b 100644 --- a/packages/core/src/space/index.ts +++ b/packages/core/src/space/index.ts @@ -1,3 +1,4 @@ export * from './client'; export * from './types'; +export * from './utils'; export * from './validation'; diff --git a/packages/core/src/space/server/queries.ts b/packages/core/src/space/server/queries.ts index 0620959e4..f95404e64 100644 --- a/packages/core/src/space/server/queries.ts +++ b/packages/core/src/space/server/queries.ts @@ -19,11 +19,17 @@ type FindAllSpacesProps = { search?: string; parentOnly?: boolean; omitSandbox?: boolean; + omitArchived?: boolean; }; export const findAllSpaces = async ( { db }: DbConfig, - { search, parentOnly = true, omitSandbox = false }: FindAllSpacesProps, + { + search, + parentOnly = true, + omitSandbox = false, + omitArchived = false, + }: FindAllSpacesProps, ) => { const results = await db .select({ @@ -50,6 +56,9 @@ export const findAllSpaces = async ( omitSandbox ? not(sql`${spaces.flags} @> '["sandbox"]'::jsonb`) : undefined, + omitArchived + ? not(sql`${spaces.flags} @> '["archived"]'::jsonb`) + : undefined, search ? sql`( -- Full-text search for exact word matches (highest priority) diff --git a/packages/core/src/space/server/web3/get-all-spaces.ts b/packages/core/src/space/server/web3/get-all-spaces.ts index 972d0e189..fd1fa0c93 100644 --- a/packages/core/src/space/server/web3/get-all-spaces.ts +++ b/packages/core/src/space/server/web3/get-all-spaces.ts @@ -2,7 +2,7 @@ import { db } from '@hypha-platform/storage-postgres'; import { findAllSpaces } from '@hypha-platform/core/server'; -import { Space } from '@hypha-platform/core/client'; +import { Space, isSpaceArchived } from '@hypha-platform/core/client'; import { fetchSpaceDetails, fetchSpaceProposalsIds, @@ -13,6 +13,7 @@ interface GetAllSpacesProps { search?: string; parentOnly?: boolean; omitSandbox?: boolean; + omitArchived?: boolean; } export async function getAllSpaces( @@ -35,9 +36,10 @@ export async function getAllSpaces( const details = formMap(web3details); const proposalsIds = formMap(web3proposalsIds); - return spaces.map((space) => { + const enrichedSpaces = spaces.map((space) => { if (space.web3SpaceId === null) { - return space; + // No web3 data = no on-chain members, treat as archived when filtering + return { ...space, memberCount: 0 }; } const spaceDetails = details.get(BigInt(space.web3SpaceId)); @@ -54,6 +56,12 @@ export async function getAllSpaces( documentCount: spaceProposals?.accepted.length ?? 0, }; }); + + if (props.omitArchived) { + return enrichedSpaces.filter((space) => !isSpaceArchived(space)); + } + + return enrichedSpaces; } catch (error) { throw new Error('Failed to get spaces', { cause: error instanceof Error ? error : new Error(String(error)), diff --git a/packages/core/src/space/utils.ts b/packages/core/src/space/utils.ts new file mode 100644 index 000000000..4bca9b5e7 --- /dev/null +++ b/packages/core/src/space/utils.ts @@ -0,0 +1,15 @@ +/** + * Determines if a space should be considered archived. + * A space is archived if: + * - It has the 'archived' flag, OR + * - It has 0 members (memberCount is explicitly 0 from Web3 data) + */ +export function isSpaceArchived(space: { + flags?: string[]; + memberCount?: number; +}): boolean { + return ( + space.flags?.includes('archived') === true || + (space.memberCount !== undefined && space.memberCount === 0) + ); +} diff --git a/packages/epics/src/spaces/components/create-space-form.tsx b/packages/epics/src/spaces/components/create-space-form.tsx index f59e5ded4..8be6fcc1e 100644 --- a/packages/epics/src/spaces/components/create-space-form.tsx +++ b/packages/epics/src/spaces/components/create-space-form.tsx @@ -300,16 +300,23 @@ export const SpaceForm = ({ [flags], ); const isDemo = React.useMemo(() => flags?.includes('demo') ?? false, [flags]); + const isArchived = React.useMemo( + () => flags?.includes('archived') ?? false, + [flags], + ); const isLive = React.useMemo( - () => !isDemo && !isSandbox, - [isDemo, isSandbox], + () => !isDemo && !isSandbox && !isArchived, + [isDemo, isSandbox, isArchived], ); const toggleSandbox = React.useCallback(() => { const current = form.getValues().flags ?? []; const next = current.includes('sandbox') ? current.filter((f) => f !== 'sandbox') - : (['sandbox', ...current.filter((f) => f !== 'demo')] as SpaceFlags[]); + : ([ + 'sandbox', + ...current.filter((f) => f !== 'demo' && f !== 'archived'), + ] as SpaceFlags[]); form.setValue('flags', next, { shouldDirty: true, shouldValidate: true }); if (next.includes('sandbox')) { form.clearErrors('categories'); @@ -320,13 +327,29 @@ export const SpaceForm = ({ const current = form.getValues().flags ?? []; const next = current.includes('demo') ? current.filter((f) => f !== 'demo') - : (['demo', ...current.filter((f) => f !== 'sandbox')] as SpaceFlags[]); + : ([ + 'demo', + ...current.filter((f) => f !== 'sandbox' && f !== 'archived'), + ] as SpaceFlags[]); + form.setValue('flags', next, { shouldDirty: true, shouldValidate: true }); + }, [form]); + + const toggleArchived = React.useCallback(() => { + const current = form.getValues().flags ?? []; + const next = current.includes('archived') + ? current.filter((f) => f !== 'archived') + : ([ + 'archived', + ...current.filter((f) => f !== 'demo' && f !== 'sandbox'), + ] as SpaceFlags[]); form.setValue('flags', next, { shouldDirty: true, shouldValidate: true }); }, [form]); const toggleLive = React.useCallback(() => { const current = form.getValues().flags ?? []; - const next = current.filter((f) => f !== 'demo' && f !== 'sandbox'); + const next = current.filter( + (f) => f !== 'demo' && f !== 'sandbox' && f !== 'archived', + ); form.setValue('flags', next, { shouldDirty: true, shouldValidate: true }); }, [form]); @@ -363,6 +386,7 @@ export const SpaceForm = ({ async (space) => { if ( !space.flags?.includes('sandbox') && + !space.flags?.includes('archived') && space.categories.length === 0 ) { showCategoriesError(); @@ -377,7 +401,11 @@ export const SpaceForm = ({ (e) => { const flags = form.getValues()['flags']; const categories = form.getValues()['categories']; - if (!flags?.includes('sandbox') && categories.length === 0) { + if ( + !flags?.includes('sandbox') && + !flags?.includes('archived') && + categories.length === 0 + ) { showCategoriesError(); } if (parentSpaceId === -1) { @@ -678,6 +706,30 @@ export const SpaceForm = ({ + {label === 'configure' && ( + +
+ Archive Mode + + + Archive this space to temporarily pause activity or + deactivate it while keeping all data and history safe. You + can reactivate it anytime by selecting a different + activation mode. + + +
+
+ )}