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

View File

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

View File

@@ -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."
/>

View File

@@ -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);
}

View File

@@ -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'];