mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +08:00
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:
+12
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+74
-16
@@ -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."
|
||||
/>
|
||||
|
||||
+112
@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user