From 14eaece0acc33a134e39aae55f4008a557b6513a Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 10 Jul 2026 14:47:19 +0800 Subject: [PATCH] feat(canvas): add shared workspace access and controls --- frontend/src/application/route.ts | 9 + frontend/src/domain/design.ts | 54 +++ frontend/src/i18n/locales/en-US.json | 37 ++ frontend/src/i18n/locales/zh-CN.json | 37 ++ frontend/src/infrastructure/designGateway.ts | 86 ++++ frontend/src/infrastructure/userMessages.ts | 8 +- frontend/src/ui/App.tsx | 49 ++ frontend/src/ui/auth/AuthProvider/index.tsx | 28 +- frontend/src/ui/auth/LoginDialog/index.tsx | 11 +- .../src/ui/components/SharePopover/index.css | 207 ++++++++ .../src/ui/components/SharePopover/index.tsx | 450 ++++++++++++++++++ .../ui/components/WorkspaceTitle/index.css | 11 + .../ui/components/WorkspaceTitle/index.tsx | 19 +- .../canvas/WorkspaceToolbar.tsx | 22 +- .../canvas/hooks/useCanvasPersistence.ts | 18 +- .../src/ui/pages/CanvasWorkspace/index.tsx | 119 +++-- frontend/src/ui/styles.css | 62 +++ frontend/tsconfig.tsbuildinfo | 2 +- 18 files changed, 1149 insertions(+), 80 deletions(-) create mode 100644 frontend/src/ui/components/SharePopover/index.css create mode 100644 frontend/src/ui/components/SharePopover/index.tsx diff --git a/frontend/src/application/route.ts b/frontend/src/application/route.ts index b2e7b6a..7de805b 100644 --- a/frontend/src/application/route.ts +++ b/frontend/src/application/route.ts @@ -2,6 +2,7 @@ export type AppRoute = | { name: "home" } | { name: "projects" } | { name: "project"; id: string } + | { name: "share"; id: string } | { name: "notFound" }; export function readRoute(): AppRoute { @@ -26,6 +27,10 @@ function routeFromPath(path: string): AppRoute | null { if (match) { return { name: "project", id: decodeURIComponent(match[1]) }; } + const shareMatch = normalized.match(/^\/share\/([^/]+)$/); + if (shareMatch) { + return { name: "share", id: decodeURIComponent(shareMatch[1]) }; + } if (normalized === "/") { return { name: "home" }; } @@ -51,3 +56,7 @@ export function openProjects() { if (typeof window === "undefined") return; window.location.hash = "/projects"; } + +export function sharePath(id: string) { + return `/share/${encodeURIComponent(id)}`; +} diff --git a/frontend/src/domain/design.ts b/frontend/src/domain/design.ts index 3055058..95cc851 100644 --- a/frontend/src/domain/design.ts +++ b/frontend/src/domain/design.ts @@ -85,6 +85,60 @@ export type Project = ProjectSummary & { messages: AgentMessage[]; }; +export type SharePermission = "private" | "canvas" | "viewer" | "editor" | "owner"; + +export type ShareCapabilities = { + canViewCanvas: boolean; + canViewChat: boolean; + canEdit: boolean; + canManage: boolean; +}; + +export type ShareAccess = { + permission: Exclude; + source: "owner" | "member" | "link"; + authenticated: boolean; + isOwner: boolean; + capabilities: ShareCapabilities; +}; + +export type SharePerson = { + id: string; + userId?: string; + name?: string; + identifier?: string; + avatarUrl?: string; + permission: Exclude; + createdAt?: string; +}; + +export type ShareVisitor = { + id: string; + userId?: string; + name: string; + identifier?: string; + avatarUrl?: string; + visitCount: number; + lastSeenAt: string; +}; + +export type ShareSettings = { + projectId: string; + shareId?: string; + linkPermission: Exclude; + owner: SharePerson; + members: SharePerson[]; + visitorCount: number; + updatedAt?: string; +}; + +export type ResolveShareResponse = { + shareId: string; + project: Project; + access: ShareAccess; + updatedAt: string; +}; + export type QuickAction = { id: string; label: string; diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 43fbd63..5e2942b 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -111,6 +111,41 @@ "share": "Share", "shareLinkCopied": "Share link copied", "shareUnavailable": "Sharing is unavailable", + "shareDesign": "Share design", + "sharePeopleWithAccess": "People with access", + "shareAddPeople": "Add people", + "shareVisitorCount": "{count} visitors", + "shareVisitors": "Visitors", + "shareAccessLevel": "Access level", + "shareAnyoneWithLink": "Anyone with the link", + "shareLinkPermission": "Link permission", + "sharePrivate": "Private", + "shareCanvasOnly": "Canvas only", + "shareCanView": "Can view", + "shareCanEdit": "Can edit", + "sharePrivateHint": "Only invited people can access", + "shareCanvasHint": "Shows only the canvas without chat or editing tools", + "shareViewerHint": "Can view the canvas and chat, but cannot edit", + "shareEditorHint": "Sign-in is required before editing", + "shareCopyLink": "Copy link", + "shareEmail": "Email", + "shareReadOnlyLink": "Shared link", + "shareViewerCopyHint": "You can copy and forward this shared link.", + "shareInvitePlaceholder": "Add an email address or phone number", + "shareInvite": "Invite", + "shareInviteAdded": "Person added", + "shareOwner": "Owner", + "shareNoVisitors": "No visitors yet.", + "shareAnonymousVisitor": "Anonymous visitor", + "shareRemoveAllTitle": "Remove access for everyone", + "shareRemoveAllDescription": "Remove everyone with access and make the design private. The old share link will stop working immediately.", + "shareRemoveAccess": "Remove access", + "shareConfirmRemove": "Confirm removal", + "shareAccessRemoved": "All shared access was removed", + "shareSettingsLoadError": "Could not load sharing settings", + "shareVisitorsLoadError": "Could not load visitors", + "shareUpdateError": "Could not update sharing settings", + "remove": "Remove", "download": "Download", "generate": "Generate", "more": "More", @@ -466,6 +501,8 @@ "messages.auth.provider_not_ready": "This sign-in service is not configured yet. Please try again later.", "messages.auth.human_verification_required": "Please complete the security check first.", "messages.auth.human_verification_failed": "Security check failed. Please verify again and retry.", + "messages.share.self_invite": "You already own this design and cannot invite yourself.", + "messages.share.member_exists": "This email address or phone number already has access.", "agentError.title.image": "Image generation did not complete", "agentError.title.partialImage": "Some images did not complete", "agentError.title.imageAction": "Image processing did not complete", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ddd4e51..46fa680 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -111,6 +111,41 @@ "share": "分享", "shareLinkCopied": "分享链接已复制", "shareUnavailable": "暂时无法分享", + "shareDesign": "分享设计", + "sharePeopleWithAccess": "有访问权限的人员", + "shareAddPeople": "添加人员", + "shareVisitorCount": "{count} 位访问者", + "shareVisitors": "访问者", + "shareAccessLevel": "访问级别", + "shareAnyoneWithLink": "知道链接的人", + "shareLinkPermission": "链接访问权限", + "sharePrivate": "私密", + "shareCanvasOnly": "仅画布", + "shareCanView": "可查看", + "shareCanEdit": "可编辑", + "sharePrivateHint": "只有受邀人员可以访问", + "shareCanvasHint": "只显示画布,不显示对话和编辑工具", + "shareViewerHint": "可以查看画布与对话,但不能编辑", + "shareEditorHint": "必须登录后才可以编辑", + "shareCopyLink": "复制链接", + "shareEmail": "邮件", + "shareReadOnlyLink": "共享链接", + "shareViewerCopyHint": "你可以复制并转发当前共享链接。", + "shareInvitePlaceholder": "添加邮箱号或手机号", + "shareInvite": "邀请", + "shareInviteAdded": "访问人员已添加", + "shareOwner": "所有者", + "shareNoVisitors": "暂无访问者加入。", + "shareAnonymousVisitor": "匿名访问者", + "shareRemoveAllTitle": "移除所有人的访问权限", + "shareRemoveAllDescription": "移除所有有访问权限的用户,并将设计设为私密。旧分享链接会立即失效。", + "shareRemoveAccess": "移除访问权限", + "shareConfirmRemove": "确认移除", + "shareAccessRemoved": "所有分享访问权限已移除", + "shareSettingsLoadError": "无法加载分享设置", + "shareVisitorsLoadError": "无法加载访问者", + "shareUpdateError": "分享设置更新失败", + "remove": "移除", "download": "下载", "generate": "生成", "more": "更多", @@ -466,6 +501,8 @@ "messages.auth.provider_not_ready": "当前登录服务暂未配置,请稍后重试。", "messages.auth.human_verification_required": "请先完成人机验证。", "messages.auth.human_verification_failed": "人机验证失败,请重新验证后再试。", + "messages.share.self_invite": "你已经是该设计的所有者,不能邀请自己。", + "messages.share.member_exists": "该邮箱或手机号已经拥有访问权限。", "agentError.title.image": "图片生成没有完成", "agentError.title.partialImage": "图片生成没有全部完成", "agentError.title.imageAction": "图片处理没有完成", diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index 0195d97..a5753c9 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -18,6 +18,10 @@ import type { NormalizedBox, Project, RecognizeObjectResponse, + ResolveShareResponse, + SharePermission, + ShareSettings, + ShareVisitor, TextExtractionSource, ProjectListResponse } from "@/domain/design"; @@ -148,6 +152,7 @@ type CandaChatHistoryResponse = { const canvasDataPrefix = "SHAKKERDATA://"; const canvasSaveSessionStorageKey = "img-infinite-canvas.canvas.saveSessionId"; +const shareVisitorStorageKey = "img-infinite-canvas.share.visitor"; function isVersionConflictError(error: unknown) { return isUserMessageKey(error, USER_MESSAGE_KEYS.conflict) || httpStatusOf(error) === 409; @@ -157,6 +162,8 @@ async function request(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers); headers.set("Content-Type", "application/json"); Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); + const shareId = activeShareId(); + if (shareId) headers.set("X-Share-Id", shareId); let response: Response; try { @@ -205,6 +212,7 @@ export const designGateway = { }, async getProject(id: string) { + if (activeShareId()) return getProjectDocument(id); try { const response = await request("/api/canva/project/queryProject", { method: "POST", @@ -246,6 +254,7 @@ export const designGateway = { }, async getProjectHistory(id: string, threadId?: string) { + if (activeShareId()) return getLegacyProjectHistory(id, threadId); try { const resolvedThreadId = threadId || (await designGateway.queryAgentLastThread(id)); if (!resolvedThreadId) return { projectId: id, threadId: "", messages: [] }; @@ -262,6 +271,9 @@ export const designGateway = { }, async listAgentThreads(id: string) { + if (activeShareId()) { + return request(`/api/projects/${id}/agent/threads`); + } try { const response = await request("/api/canva/agent/agentThreadList", { method: "POST", @@ -364,6 +376,7 @@ export const designGateway = { submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record) { return request("/api/generator/tasks", { method: "POST", + headers: { "X-Share-Project-Id": projectId }, body: JSON.stringify({ cid: createClientId(), project_id: projectId, @@ -408,6 +421,53 @@ export const designGateway = { }); }, + resolveShare(shareId: string) { + return request(`/api/shares/${encodeURIComponent(shareId)}`, { + headers: { "X-Share-Visitor": getShareVisitorId() } + }); + }, + + getShareSettings(projectId: string) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing`); + }, + + updateShareLink(projectId: string, permission: Exclude) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/link`, { + method: "PUT", + body: JSON.stringify({ permission }) + }); + }, + + inviteShareMember(projectId: string, identifier: string, permission: Exclude) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members`, { + method: "POST", + body: JSON.stringify({ identifier, permission }) + }); + }, + + updateShareMember(projectId: string, memberId: string, permission: Exclude) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, { + method: "PATCH", + body: JSON.stringify({ permission }) + }); + }, + + deleteShareMember(projectId: string, memberId: string) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members/${memberId}`, { + method: "DELETE" + }); + }, + + listShareVisitors(projectId: string) { + return request<{ visitors: ShareVisitor[] }>(`/api/projects/${projectId}/sharing/visitors`); + }, + + revokeShareAccess(projectId: string) { + return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/access`, { + method: "DELETE" + }); + }, + projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) { const controller = new AbortController(); let settled = false; @@ -561,6 +621,12 @@ async function getLegacyProjectHistory(id: string, threadId?: string) { } async function saveProjectCanvas(projectId: string, payload: object) { + if (activeShareId()) { + return request(`/api/projects/${projectId}/canvas`, { + method: "PATCH", + body: JSON.stringify(payload) + }); + } try { return await request("/api/canva/project/saveProject", { method: "POST", @@ -575,6 +641,26 @@ async function saveProjectCanvas(projectId: string, payload: object) { } } +function activeShareId() { + if (typeof window === "undefined") return ""; + const hashPath = window.location.hash.replace(/^#/, ""); + const path = hashPath || window.location.pathname; + const [pathOnly] = path.split(/[?#]/); + const match = pathOnly.replace(/\/+$/, "").match(/^\/share\/([^/]+)$/); + return match ? decodeURIComponent(match[1]) : ""; +} + +function getShareVisitorId() { + if (typeof window === "undefined") return ""; + const stored = window.localStorage.getItem(shareVisitorStorageKey)?.trim(); + if (stored) return stored; + const bytes = new Uint8Array(18); + window.crypto.getRandomValues(bytes); + const visitorId = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); + window.localStorage.setItem(shareVisitorStorageKey, visitorId); + return visitorId; +} + function messageFromCandaHistoryItem(item: CandaChatHistoryResponse["data"][number], fallbackThreadId: string): AgentMessage { const type = item.type || "assistant"; const role: AgentMessage["role"] = type === "user" ? "user" : type === "tool_use" || item.tool_call_id ? "tool" : "assistant"; diff --git a/frontend/src/infrastructure/userMessages.ts b/frontend/src/infrastructure/userMessages.ts index 5e76f6c..c89b3bf 100644 --- a/frontend/src/infrastructure/userMessages.ts +++ b/frontend/src/infrastructure/userMessages.ts @@ -18,7 +18,9 @@ export const USER_MESSAGE_KEYS = { authInvalidRegion: "messages.auth.invalid_region", authProviderNotReady: "messages.auth.provider_not_ready", authHumanVerificationRequired: "messages.auth.human_verification_required", - authHumanVerificationFailed: "messages.auth.human_verification_failed" + authHumanVerificationFailed: "messages.auth.human_verification_failed", + shareSelfInvite: "messages.share.self_invite", + shareMemberExists: "messages.share.member_exists" } as const; const HTTP_STATUS_MESSAGE_KEYS: Record = { @@ -40,7 +42,9 @@ const BACKEND_STATUS_MESSAGE_KEYS: Record = { "auth.invalid_region": USER_MESSAGE_KEYS.authInvalidRegion, "auth.provider_not_ready": USER_MESSAGE_KEYS.authProviderNotReady, "auth.human_verification_required": USER_MESSAGE_KEYS.authHumanVerificationRequired, - "auth.human_verification_failed": USER_MESSAGE_KEYS.authHumanVerificationFailed + "auth.human_verification_failed": USER_MESSAGE_KEYS.authHumanVerificationFailed, + "share.self_invite": USER_MESSAGE_KEYS.shareSelfInvite, + "share.member_exists": USER_MESSAGE_KEYS.shareMemberExists }; const RAW_STATUS_MESSAGE_KEYS: Array<{ pattern: RegExp; key: string }> = [ diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 17f2c5d..8232125 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -2,7 +2,10 @@ import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; +import type { ResolveShareResponse } from "@/domain/design"; import { readRoute, type AppRoute } from "@/application/route"; +import { designGateway } from "@/infrastructure/designGateway"; +import { httpStatusOf } from "@/infrastructure/userMessages"; import { BrandMark } from "@/ui/components/BrandMark"; import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace"; import { HomePage } from "@/ui/pages/HomePage"; @@ -41,11 +44,57 @@ export function App() { if (route.name === "project" && !isAuthenticated) return ; if (route.name === "project") return ; + if (route.name === "share") return ; if (route.name === "projects") return ; if (route.name === "notFound") return ; return ; } +function SharedCanvasRoute({ shareId }: { shareId: string }) { + const { isAuthenticated, openRequiredLogin } = useAuth(); + const [resolved, setResolved] = useState(null); + const [state, setState] = useState<"loading" | "not-found" | "error">("loading"); + + useEffect(() => { + let active = true; + let releaseRequiredLogin: (() => void) | undefined; + setResolved(null); + setState("loading"); + designGateway + .resolveShare(shareId) + .then((response) => { + if (!active) return; + setResolved(response); + }) + .catch((error: unknown) => { + if (!active) return; + const status = httpStatusOf(error); + if (status === 401 && !isAuthenticated) { + releaseRequiredLogin = openRequiredLogin(); + return; + } + setState(status === 403 || status === 404 ? "not-found" : "error"); + }); + return () => { + active = false; + releaseRequiredLogin?.(); + }; + }, [isAuthenticated, openRequiredLogin, shareId]); + + if (resolved) { + return ; + } + if (state === "not-found") return ; + if (state === "error") { + return ( +
+ Unable to open this shared canvas. +
+ ); + } + return ; +} + function ProjectAuthPreview() { return (