From 60de882a300a5fc64f074ada72d3a884ea575596 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:07:41 +0800 Subject: [PATCH] feat: shared link list (#14200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #14200** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit ## Release Notes * **New Features** * Added a "Shared Links" panel to workspace management, enabling admins to view all published documents within a workspace * Added publication date tracking for published documents, now displayed alongside shared links * **Chores** * Removed deprecated `publicPages` field; use `publicDocs` instead ✏️ Tip: You can customize this high-level summary in your review settings. --- .../migration.sql | 12 ++ packages/backend/server/schema.prisma | 1 + .../src/core/workspaces/resolvers/admin.ts | 27 +++++ .../src/core/workspaces/resolvers/doc.ts | 8 -- packages/backend/server/src/models/doc.ts | 10 +- packages/backend/server/src/schema.gql | 8 +- .../src/graphql/admin/admin-workspace.gql | 5 + packages/common/graphql/src/graphql/index.ts | 5 + packages/common/graphql/src/schema.ts | 16 ++- .../modules/workspaces/components/columns.tsx | 2 +- .../components/data-table-row-actions.tsx | 90 +++++++++++--- .../workspace-shared-links-panel.tsx | 112 ++++++++++++++++++ .../admin/src/modules/workspaces/schema.ts | 1 + 13 files changed, 264 insertions(+), 33 deletions(-) create mode 100644 packages/backend/server/migrations/20260102120625_workspace_doc_published_at/migration.sql create mode 100644 packages/frontend/admin/src/modules/workspaces/components/workspace-shared-links-panel.tsx diff --git a/packages/backend/server/migrations/20260102120625_workspace_doc_published_at/migration.sql b/packages/backend/server/migrations/20260102120625_workspace_doc_published_at/migration.sql new file mode 100644 index 000000000..7d1e11941 --- /dev/null +++ b/packages/backend/server/migrations/20260102120625_workspace_doc_published_at/migration.sql @@ -0,0 +1,12 @@ +-- Add published_at to workspace_pages to track when a doc was shared +ALTER TABLE "workspace_pages" +ADD COLUMN IF NOT EXISTS "published_at" TIMESTAMPTZ(3); + +-- Backfill existing public docs using the snapshot updated_at +UPDATE "workspace_pages" wp +SET "published_at" = s."updated_at" +FROM "snapshots" s +WHERE wp."workspace_id" = s."workspace_id" + AND wp."page_id" = s."guid" + AND wp."public" = TRUE + AND wp."published_at" IS NULL; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 6049254bc..53fc0339d 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -158,6 +158,7 @@ model WorkspaceDoc { blocked Boolean @default(false) title String? @db.VarChar summary String? @db.VarChar + publishedAt DateTime? @map("published_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 7ef37ac1f..6b0d5efc6 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -79,6 +79,18 @@ class AdminWorkspaceMember { status!: WorkspaceMemberStatus; } +@ObjectType() +class AdminWorkspaceSharedLink { + @Field() + docId!: string; + + @Field(() => String, { nullable: true }) + title?: string | null; + + @Field(() => Date, { nullable: true }) + publishedAt?: Date | null; +} + @ObjectType() export class AdminWorkspace { @Field() @@ -128,6 +140,9 @@ export class AdminWorkspace { @Field(() => SafeIntResolver) blobSize!: number; + + @Field(() => [AdminWorkspaceSharedLink]) + sharedLinks!: AdminWorkspaceSharedLink[]; } @InputType() @@ -250,6 +265,18 @@ export class AdminWorkspaceResolver { })); } + @ResolveField(() => [AdminWorkspaceSharedLink], { + description: 'Shared links of workspace', + }) + async sharedLinks(@Parent() workspace: AdminWorkspace) { + const publicDocs = await this.models.doc.findPublics(workspace.id, 'desc'); + return publicDocs.map(doc => ({ + docId: doc.docId, + title: doc.title, + publishedAt: doc.publishedAt ?? null, + })); + } + @Mutation(() => AdminWorkspace, { description: 'Update workspace flags and features for admin', nullable: true, diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index bd480e972..ffdc9861d 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -230,14 +230,6 @@ export class WorkspaceDocResolver { }; } - @ResolveField(() => [DocType], { - complexity: 2, - deprecationReason: 'use [WorkspaceType.publicDocs] instead', - }) - async publicPages(@Parent() workspace: WorkspaceType) { - return this.publicDocs(workspace); - } - @ResolveField(() => [DocType], { description: 'Get public docs of a workspace', complexity: 2, diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index ec77e07f7..ab3f68c7e 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -484,12 +484,10 @@ export class DocModel extends BaseModel { /** * Find the workspace public doc metas. */ - async findPublics(workspaceId: string) { + async findPublics(workspaceId: string, order: 'asc' | 'desc' = 'asc') { return await this.db.workspaceDoc.findMany({ - where: { - workspaceId, - public: true, - }, + where: { workspaceId, public: true }, + orderBy: { publishedAt: order }, }); } @@ -524,6 +522,7 @@ export class DocModel extends BaseModel { return await this.upsertMeta(workspaceId, docId, { public: true, mode, + publishedAt: new Date(), }); } @@ -536,6 +535,7 @@ export class DocModel extends BaseModel { return await this.upsertMeta(workspaceId, docId, { public: false, + publishedAt: null, }); } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 77e248785..10622ed78 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -60,6 +60,7 @@ type AdminWorkspace { owner: WorkspaceUserType public: Boolean! publicPageCount: Int! + sharedLinks: [AdminWorkspaceSharedLink!]! snapshotCount: Int! snapshotSize: SafeInt! } @@ -73,6 +74,12 @@ type AdminWorkspaceMember { status: WorkspaceMemberStatus! } +type AdminWorkspaceSharedLink { + docId: String! + publishedAt: DateTime + title: String +} + enum AdminWorkspaceSort { BlobCount BlobSize @@ -2466,7 +2473,6 @@ type WorkspaceType { """Get public page of a workspace by page id.""" publicPage(pageId: String!): DocType @deprecated(reason: "use [WorkspaceType.doc] instead") - publicPages: [DocType!]! @deprecated(reason: "use [WorkspaceType.publicDocs] instead") """quota of workspace""" quota: WorkspaceQuotaType! diff --git a/packages/common/graphql/src/graphql/admin/admin-workspace.gql b/packages/common/graphql/src/graphql/admin/admin-workspace.gql index 201dfec56..91e281012 100644 --- a/packages/common/graphql/src/graphql/admin/admin-workspace.gql +++ b/packages/common/graphql/src/graphql/admin/admin-workspace.gql @@ -26,6 +26,11 @@ query adminWorkspace( snapshotSize blobCount blobSize + sharedLinks { + docId + title + publishedAt + } members(skip: $memberSkip, take: $memberTake, query: $memberQuery) { id name diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index e5980bfa1..18e48bb06 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -190,6 +190,11 @@ export const adminWorkspaceQuery = { snapshotSize blobCount blobSize + sharedLinks { + docId + title + publishedAt + } members(skip: $memberSkip, take: $memberTake, query: $memberQuery) { id name diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 922404cb2..6d7589cd8 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -96,6 +96,7 @@ export interface AdminWorkspace { owner: Maybe; public: Scalars['Boolean']['output']; publicPageCount: Scalars['Int']['output']; + sharedLinks: Array; snapshotCount: Scalars['Int']['output']; snapshotSize: Scalars['SafeInt']['output']; } @@ -116,6 +117,13 @@ export interface AdminWorkspaceMember { status: WorkspaceMemberStatus; } +export interface AdminWorkspaceSharedLink { + __typename?: 'AdminWorkspaceSharedLink'; + docId: Scalars['String']['output']; + publishedAt: Maybe; + title: Maybe; +} + export enum AdminWorkspaceSort { BlobCount = 'BlobCount', BlobSize = 'BlobSize', @@ -3142,8 +3150,6 @@ export interface WorkspaceType { * @deprecated use [WorkspaceType.doc] instead */ publicPage: Maybe; - /** @deprecated use [WorkspaceType.publicDocs] instead */ - publicPages: Array; /** quota of workspace */ quota: WorkspaceQuotaType; /** Get recently updated docs of a workspace */ @@ -3378,6 +3384,12 @@ export type AdminWorkspaceQuery = { email: string; avatarUrl: string | null; } | null; + sharedLinks: Array<{ + __typename?: 'AdminWorkspaceSharedLink'; + docId: string; + title: string | null; + publishedAt: string | null; + }>; members: Array<{ __typename?: 'AdminWorkspaceMember'; id: string; diff --git a/packages/frontend/admin/src/modules/workspaces/components/columns.tsx b/packages/frontend/admin/src/modules/workspaces/components/columns.tsx index e88988197..a334adc3d 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/columns.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/columns.tsx @@ -154,7 +154,7 @@ export const useColumns = () => { { id: 'actions', meta: { - className: 'w-[80px] justify-end', + className: 'w-[190px] justify-end', }, header: () => ( Actions diff --git a/packages/frontend/admin/src/modules/workspaces/components/data-table-row-actions.tsx b/packages/frontend/admin/src/modules/workspaces/components/data-table-row-actions.tsx index 58a7834ff..8d9267f62 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/data-table-row-actions.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/data-table-row-actions.tsx @@ -1,11 +1,12 @@ import { Button } from '@affine/admin/components/ui/button'; -import { EditIcon } from '@blocksuite/icons/rc'; +import { EditIcon, LinkIcon } from '@blocksuite/icons/rc'; import { useCallback, useState } from 'react'; import { DiscardChanges } from '../../../components/shared/discard-changes'; import { useRightPanel } from '../../panel/context'; import type { WorkspaceListItem } from '../schema'; import { WorkspacePanel } from './workspace-panel'; +import { WorkspaceSharedLinksPanel } from './workspace-shared-links-panel'; export function DataTableRowActions({ workspace, @@ -13,6 +14,9 @@ export function DataTableRowActions({ workspace: WorkspaceListItem; }) { const [discardDialogOpen, setDiscardDialogOpen] = useState(false); + const [pendingAction, setPendingAction] = useState< + 'edit' | 'sharedLinks' | null + >(null); const { setPanelContent, openPanel, @@ -22,7 +26,7 @@ export function DataTableRowActions({ setHasDirtyChanges, } = useRightPanel(); - const handleConfirm = useCallback(() => { + const openWorkspacePanel = useCallback(() => { setHasDirtyChanges(false); setPanelContent( @@ -39,35 +43,89 @@ export function DataTableRowActions({ workspace.id, ]); + const openSharedLinksPanel = useCallback(() => { + setHasDirtyChanges(false); + setPanelContent( + + ); + if (!isOpen) { + openPanel(); + } + }, [ + closePanel, + isOpen, + openPanel, + setHasDirtyChanges, + setPanelContent, + workspace.id, + ]); + const handleEdit = useCallback(() => { if (hasDirtyChanges) { + setPendingAction('edit'); setDiscardDialogOpen(true); return; } - handleConfirm(); - }, [handleConfirm, hasDirtyChanges]); + openWorkspacePanel(); + }, [hasDirtyChanges, openWorkspacePanel]); + + const handleSharedLinks = useCallback(() => { + if (hasDirtyChanges) { + setPendingAction('sharedLinks'); + setDiscardDialogOpen(true); + return; + } + openSharedLinksPanel(); + }, [hasDirtyChanges, openSharedLinksPanel]); const handleDiscardConfirm = useCallback(() => { setDiscardDialogOpen(false); setHasDirtyChanges(false); - handleConfirm(); - }, [handleConfirm, setHasDirtyChanges]); + if (pendingAction === 'sharedLinks') { + openSharedLinksPanel(); + } else { + openWorkspacePanel(); + } + setPendingAction(null); + }, [ + openSharedLinksPanel, + openWorkspacePanel, + pendingAction, + setHasDirtyChanges, + ]); return ( <> - - - Edit - + + + + Edit + + + + Shared links + + setDiscardDialogOpen(false)} + onClose={() => { + setDiscardDialogOpen(false); + setPendingAction(null); + }} onConfirm={handleDiscardConfirm} description="Changes to this workspace will not be saved." /> diff --git a/packages/frontend/admin/src/modules/workspaces/components/workspace-shared-links-panel.tsx b/packages/frontend/admin/src/modules/workspaces/components/workspace-shared-links-panel.tsx new file mode 100644 index 000000000..f691ce965 --- /dev/null +++ b/packages/frontend/admin/src/modules/workspaces/components/workspace-shared-links-panel.tsx @@ -0,0 +1,112 @@ +import { Separator } from '@affine/admin/components/ui/separator'; +import { adminWorkspaceQuery } from '@affine/graphql'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useMemo } from 'react'; + +import { useQuery } from '../../../use-query'; +import { RightPanelHeader } from '../../header'; +import type { WorkspaceSharedLink } from '../schema'; + +export function WorkspaceSharedLinksPanel({ + workspaceId, + onClose, +}: { + workspaceId: string; + onClose: () => void; +}) { + const { data } = useQuery({ + query: adminWorkspaceQuery, + variables: { + id: workspaceId, + memberSkip: 0, + memberTake: 0, + memberQuery: undefined, + }, + }); + + const workspace = data?.adminWorkspace; + + const sharedLinks = useMemo(() => { + const links = workspace?.sharedLinks ?? []; + return [...links].sort((a, b) => { + const aTime = a.publishedAt ? new Date(a.publishedAt).getTime() : 0; + const bTime = b.publishedAt ? new Date(b.publishedAt).getTime() : 0; + return bTime - aTime; + }); + }, [workspace?.sharedLinks]); + + if (!workspace) { + return ( + + + + Workspace not found. + + + ); + } + + return ( + + + + {sharedLinks.length === 0 ? ( + + No shared links. + + ) : ( + + {sharedLinks.map(link => ( + + ))} + + )} + + + ); +} + +function SharedLinkItem({ link }: { link: WorkspaceSharedLink }) { + const title = link.title || link.docId; + const sharedDate = formatSharedDate(link.publishedAt); + + return ( + + {title} + + + + Shared on {sharedDate} + + + + ); +} + +function formatSharedDate(publishedAt?: string | null) { + if (!publishedAt) { + return 'Unknown'; + } + + const date = new Date(publishedAt); + if (Number.isNaN(date.getTime())) { + return 'Unknown'; + } + return date.toISOString().slice(0, 10); +} diff --git a/packages/frontend/admin/src/modules/workspaces/schema.ts b/packages/frontend/admin/src/modules/workspaces/schema.ts index 737f1a5dc..a50d445b6 100644 --- a/packages/frontend/admin/src/modules/workspaces/schema.ts +++ b/packages/frontend/admin/src/modules/workspaces/schema.ts @@ -10,6 +10,7 @@ export type WorkspaceDetail = NonNullable< AdminWorkspaceQuery['adminWorkspace'] >; export type WorkspaceMember = WorkspaceDetail['members'][0]; +export type WorkspaceSharedLink = WorkspaceDetail['sharedLinks'][0]; export type WorkspaceUpdateInput = AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];