feat: shared link list (#14200)

#### PR Dependency Tree


* **PR #14200** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-01-02 21:07:41 +08:00
committed by GitHub
parent 9f96633b33
commit 60de882a30
13 changed files with 264 additions and 33 deletions
@@ -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;
+1
View File
@@ -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)
@@ -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,
@@ -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,
+5 -5
View File
@@ -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,
});
}
+7 -1
View File
@@ -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!
@@ -26,6 +26,11 @@ query adminWorkspace(
snapshotSize
blobCount
blobSize
sharedLinks {
docId
title
publishedAt
}
members(skip: $memberSkip, take: $memberTake, query: $memberQuery) {
id
name
@@ -190,6 +190,11 @@ export const adminWorkspaceQuery = {
snapshotSize
blobCount
blobSize
sharedLinks {
docId
title
publishedAt
}
members(skip: $memberSkip, take: $memberTake, query: $memberQuery) {
id
name
+14 -2
View File
@@ -96,6 +96,7 @@ export interface AdminWorkspace {
owner: Maybe<WorkspaceUserType>;
public: Scalars['Boolean']['output'];
publicPageCount: Scalars['Int']['output'];
sharedLinks: Array<AdminWorkspaceSharedLink>;
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<Scalars['DateTime']['output']>;
title: Maybe<Scalars['String']['output']>;
}
export enum AdminWorkspaceSort {
BlobCount = 'BlobCount',
BlobSize = 'BlobSize',
@@ -3142,8 +3150,6 @@ export interface WorkspaceType {
* @deprecated use [WorkspaceType.doc] instead
*/
publicPage: Maybe<DocType>;
/** @deprecated use [WorkspaceType.publicDocs] instead */
publicPages: Array<DocType>;
/** 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;
@@ -154,7 +154,7 @@ export const useColumns = () => {
{
id: 'actions',
meta: {
className: 'w-[80px] justify-end',
className: 'w-[190px] justify-end',
},
header: () => (
<div className="text-xs font-medium text-right">Actions</div>
@@ -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(
<WorkspacePanel workspaceId={workspace.id} onClose={closePanel} />
@@ -39,35 +43,89 @@ export function DataTableRowActions({
workspace.id,
]);
const openSharedLinksPanel = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent(
<WorkspaceSharedLinksPanel
workspaceId={workspace.id}
onClose={closePanel}
/>
);
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 (
<>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleEdit}
>
<EditIcon fontSize={18} />
<span>Edit</span>
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleEdit}
>
<EditIcon fontSize={18} />
<span>Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleSharedLinks}
>
<LinkIcon fontSize={18} />
<span>Shared links</span>
</Button>
</div>
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={() => setDiscardDialogOpen(false)}
onClose={() => {
setDiscardDialogOpen(false);
setPendingAction(null);
}}
onConfirm={handleDiscardConfirm}
description="Changes to this workspace will not be saved."
/>
@@ -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<WorkspaceSharedLink[]>(() => {
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 (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Shared Links"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace not found.
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Shared Links"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div className="p-4 flex flex-col gap-3 overflow-y-auto">
{sharedLinks.length === 0 ? (
<div
className="text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
No shared links.
</div>
) : (
<div className="flex flex-col divide-y rounded-md border">
{sharedLinks.map(link => (
<SharedLinkItem key={link.docId} link={link} />
))}
</div>
)}
</div>
</div>
);
}
function SharedLinkItem({ link }: { link: WorkspaceSharedLink }) {
const title = link.title || link.docId;
const sharedDate = formatSharedDate(link.publishedAt);
return (
<div className="flex flex-col gap-1 px-3 py-3">
<div className="text-sm font-medium truncate">{title}</div>
<div className="flex items-center gap-2 text-xs">
<Separator className="h-3" orientation="vertical" />
<span style={{ color: cssVarV2('text/secondary') }}>
Shared on {sharedDate}
</span>
</div>
</div>
);
}
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);
}
@@ -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'];