feat(canvas): add shared workspace access and controls

This commit is contained in:
2026-07-10 14:47:19 +08:00
parent f288eb1365
commit 14eaece0ac
18 changed files with 1149 additions and 80 deletions
+9
View File
@@ -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)}`;
}
+54
View File
@@ -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<SharePermission, "private">;
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<SharePermission, "private">;
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<SharePermission, "owner">;
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;
+37
View File
@@ -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",
+37
View File
@@ -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": "图片处理没有完成",
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<QueryProjectResponse>("/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<AgentThreadListResponse>(`/api/projects/${id}/agent/threads`);
}
try {
const response = await request<LovartAgentThreadListResponse>("/api/canva/agent/agentThreadList", {
method: "POST",
@@ -364,6 +376,7 @@ export const designGateway = {
submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record<string, unknown>) {
return request<GeneratorTaskSubmitResponse>("/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<ResolveShareResponse>(`/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<SharePermission, "owner">) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/link`, {
method: "PUT",
body: JSON.stringify({ permission })
});
},
inviteShareMember(projectId: string, identifier: string, permission: Exclude<SharePermission, "private" | "owner">) {
return request<{ settings: ShareSettings }>(`/api/projects/${projectId}/sharing/members`, {
method: "POST",
body: JSON.stringify({ identifier, permission })
});
},
updateShareMember(projectId: string, memberId: string, permission: Exclude<SharePermission, "private" | "owner">) {
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<SaveProjectResponse>(`/api/projects/${projectId}/canvas`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
try {
return await request<SaveProjectResponse>("/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";
+6 -2
View File
@@ -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<number, string> = {
@@ -40,7 +42,9 @@ const BACKEND_STATUS_MESSAGE_KEYS: Record<string, string> = {
"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 }> = [
+49
View File
@@ -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 <ProjectAuthPreview />;
if (route.name === "project") return <CanvasWorkspace projectId={route.id} />;
if (route.name === "share") return <SharedCanvasRoute shareId={route.id} />;
if (route.name === "projects") return <ProjectsRoutePage />;
if (route.name === "notFound") return <NotFoundPage />;
return <HomePage />;
}
function SharedCanvasRoute({ shareId }: { shareId: string }) {
const { isAuthenticated, openRequiredLogin } = useAuth();
const [resolved, setResolved] = useState<ResolveShareResponse | null>(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 <CanvasWorkspace projectId={resolved.project.id} shareAccess={resolved.access} />;
}
if (state === "not-found") return <NotFoundPage />;
if (state === "error") {
return (
<div className="workspace-loading">
<span>Unable to open this shared canvas.</span>
</div>
);
}
return <ProjectAuthPreview />;
}
function ProjectAuthPreview() {
return (
<div className="project-auth-shell" aria-hidden="true">
+26 -2
View File
@@ -14,6 +14,7 @@ type AuthContextValue = {
isAuthenticated: boolean;
isAuthChecking: boolean;
openLogin: (action?: AuthAction) => void;
openRequiredLogin: () => () => void;
requireAuth: (action: AuthAction) => void;
closeLogin: () => void;
authenticate: (session: AuthSession) => void;
@@ -25,6 +26,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const pendingActionRef = useRef<AuthAction | null>(null);
const requiredLoginLocksRef = useRef(new Set<symbol>());
const [authState, setAuthState] = useState(() => {
const storedSession = readStoredAuthSession();
return {
@@ -33,6 +35,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
});
const [loginOpen, setLoginOpen] = useState(false);
const [requiredLoginCount, setRequiredLoginCount] = useState(0);
const session = authState.session;
const isAuthChecking = authState.checking;
@@ -67,6 +70,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [session?.accessToken]);
const closeLogin = useCallback(() => {
if (requiredLoginLocksRef.current.size > 0) return;
pendingActionRef.current = null;
setLoginOpen(false);
}, []);
@@ -76,9 +80,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoginOpen(true);
}, []);
const openRequiredLogin = useCallback(() => {
const lock = Symbol("required-login");
requiredLoginLocksRef.current.add(lock);
setRequiredLoginCount(requiredLoginLocksRef.current.size);
setLoginOpen(true);
return () => {
if (!requiredLoginLocksRef.current.delete(lock)) return;
const remaining = requiredLoginLocksRef.current.size;
setRequiredLoginCount(remaining);
if (remaining === 0) {
pendingActionRef.current = null;
setLoginOpen(false);
}
};
}, []);
const authenticate = useCallback((nextSession: AuthSession) => {
const stored = storeAuthSession(nextSession);
setAuthState({ session: stored, checking: false });
requiredLoginLocksRef.current.clear();
setRequiredLoginCount(0);
setLoginOpen(false);
const action = pendingActionRef.current;
pendingActionRef.current = null;
@@ -123,19 +146,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: Boolean(session?.accessToken),
isAuthChecking,
openLogin,
openRequiredLogin,
requireAuth,
closeLogin,
authenticate,
updateUser,
logout
}),
[authenticate, closeLogin, isAuthChecking, logout, openLogin, requireAuth, session, updateUser]
[authenticate, closeLogin, isAuthChecking, logout, openLogin, openRequiredLogin, requireAuth, session, updateUser]
);
return (
<AuthContext.Provider value={value}>
{children}
<LoginDialog open={loginOpen} onClose={closeLogin} onAuthenticated={authenticate} />
<LoginDialog open={loginOpen} dismissible={requiredLoginCount === 0} onClose={closeLogin} onAuthenticated={authenticate} />
</AuthContext.Provider>
);
}
+7 -4
View File
@@ -13,6 +13,7 @@ import "./index.css";
type LoginDialogProps = {
open: boolean;
dismissible?: boolean;
onClose: () => void;
onAuthenticated: (session: AuthSession) => void;
};
@@ -40,7 +41,7 @@ declare global {
let googleScriptPromise: Promise<void> | null = null;
export function LoginDialog({ open, onClose, onAuthenticated }: LoginDialogProps) {
export function LoginDialog({ open, dismissible = true, onClose, onAuthenticated }: LoginDialogProps) {
const { t } = useI18n();
const toUserMessageText = useUserMessage();
const emailInputRef = useRef<HTMLInputElement | null>(null);
@@ -225,9 +226,11 @@ export function LoginDialog({ open, onClose, onAuthenticated }: LoginDialogProps
return (
<div className="login-backdrop" role="presentation">
<section className="login-modal" role="dialog" aria-modal="true" aria-labelledby="login-title">
<button className="login-close" type="button" onClick={onClose} aria-label={t("close")}>
<X size={34} strokeWidth={1.5} />
</button>
{dismissible && (
<button className="login-close" type="button" onClick={onClose} aria-label={t("close")}>
<X size={34} strokeWidth={1.5} />
</button>
)}
<div className="login-brand-row">
<BrandLogo label={t("appName")} />
@@ -0,0 +1,207 @@
.share-popover {
width: min(390px, calc(100vw - 24px));
max-height: min(720px, calc(100vh - 88px));
padding: 0;
overflow: auto;
border: 1px solid rgba(25, 29, 36, 0.12);
border-radius: 16px;
color: #25272b;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 70px rgba(25, 28, 34, 0.2), 0 3px 12px rgba(25, 28, 34, 0.08);
backdrop-filter: blur(24px);
}
.share-popover-header {
position: sticky;
z-index: 3;
top: 0;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px;
border-bottom: 1px solid #eceef1;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(18px);
}
.share-popover-header > div,
.share-header-trailing {
display: flex;
align-items: center;
gap: 6px;
}
.share-popover-header h2 {
margin: 0;
font-size: 15px;
font-weight: 760;
letter-spacing: -0.01em;
}
.share-popover-header button {
height: 30px;
min-width: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 7px;
border-radius: 8px;
color: #777d86;
background: transparent;
}
.share-popover-header button:hover { color: #202327; background: #f3f4f6; }
.share-popover-header .share-header-visitors { padding: 0 8px; font-size: 12px; font-weight: 650; }
.share-section {
padding: 15px 16px;
border-bottom: 1px solid #eceef1;
}
.share-section:last-child { border-bottom: 0; }
.share-section h3 { margin: 0 0 10px; font-size: 14px; font-weight: 760; }
.share-section p { margin: 7px 0 0; color: #8a9099; font-size: 12px; line-height: 1.55; }
.share-add-person {
width: 100%;
height: 40px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
border-radius: 11px;
color: #8c929b;
background: #f6f7f8;
font-size: 14px;
font-weight: 650;
text-align: left;
}
.share-add-person:hover { color: #32353a; background: #f0f2f4; }
.share-avatar-stack { display: flex; align-items: center; padding-top: 12px; }
.share-avatar-stack .share-avatar,
.share-avatar-stack > button,
.share-avatar-more { margin-left: -4px; border: 2px solid #fff; }
.share-avatar-stack .share-avatar:first-child { margin-left: 0; }
.share-avatar-stack > button,
.share-avatar-more { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; color: #7c828b; background: #f3f4f6; font-size: 11px; font-weight: 750; }
.share-avatar-stack > button:hover { color: #202327; background: #e9ebee; }
.share-avatar {
width: 34px;
height: 34px;
flex: 0 0 34px;
display: inline-grid;
place-items: center;
overflow: hidden;
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, #8d6f63, #b59586);
font-size: 12px;
font-weight: 720;
}
.share-avatar img { width: 100%; height: 100%; object-fit: cover; }
.share-access-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 116px;
align-items: center;
gap: 10px;
}
.share-access-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; color: #7a818b; background: #f5f6f7; }
.share-audience-label { min-width: 0; display: grid; gap: 2px; }
.share-audience-label > span { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.share-audience-label small { overflow: hidden; color: #969ba3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.share-popover select {
height: 34px;
min-width: 0;
padding: 0 30px 0 10px;
border: 1px solid #e1e4e8;
border-radius: 9px;
outline: none;
color: #303339;
background: #f8f9fa;
font: inherit;
font-size: 12px;
font-weight: 680;
}
.share-popover select:focus { border-color: #adb3bc; box-shadow: 0 0 0 3px rgba(33, 38, 45, 0.06); }
.share-actions-section { background: #fbfbfc; }
.share-copy-button {
width: 100%;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 12px;
color: #fff;
background: #232426;
box-shadow: 0 6px 16px rgba(19, 20, 22, 0.16);
font-size: 14px;
font-weight: 720;
}
.share-copy-button:hover { background: #111214; transform: translateY(-1px); }
.share-copy-button:disabled { opacity: 0.55; transform: none; }
.share-social-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; }
.share-social-grid button { height: 38px; display: flex; align-items: center; gap: 10px; padding: 0 12px; border-radius: 10px; color: #34373c; background: #f2f3f5; font-size: 13px; font-weight: 670; }
.share-social-grid button:hover { color: #111214; background: #e9ebee; }
.share-social-grid button:disabled { color: #a5abb3; background: #f5f6f7; cursor: not-allowed; }
.share-social-grid svg { color: #8a9097; }
.share-viewer-summary { display: flex; align-items: flex-start; gap: 12px; }
.share-viewer-summary strong { display: block; font-size: 14px; }
.share-invite-section { border-bottom: 0; }
.share-invite-input { height: 42px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid #dfe2e6; border-radius: 11px; color: #9197a0; background: #fff; }
.share-invite-input:focus-within { border-color: #aeb4bd; box-shadow: 0 0 0 3px rgba(33, 38, 45, 0.06); }
.share-invite-input input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 13px; }
.share-invite-controls { display: grid; grid-template-columns: minmax(0, 1fr) 88px; gap: 9px; margin-top: 10px; }
.share-invite-controls button { height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 9px; color: #fff; background: #27292c; font-size: 12px; font-weight: 720; }
.share-invite-controls button:disabled { color: #fff; background: #c8cbd0; }
.share-member-list { display: grid; gap: 2px; margin-top: 16px; padding-top: 10px; border-top: 1px solid #eceef1; }
.share-member-row { min-height: 50px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 9px; }
.share-member-row > div { min-width: 0; display: grid; gap: 2px; }
.share-member-row strong,
.share-member-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-member-row strong { font-size: 12px; font-weight: 720; }
.share-member-row small { color: #979ca4; font-size: 10px; }
.share-member-row select { width: 98px; height: 30px; }
.share-owner-label { color: #8a9099; font-size: 11px; font-weight: 650; }
.share-member-remove { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: #a0a5ad; }
.share-member-remove:hover { color: #c43d43; background: #fff0f1; }
.share-visitors-section { max-height: 310px; overflow-y: auto; }
.share-empty-state { min-height: 112px; display: grid; place-items: center; border-radius: 12px; color: #9ba0a8; background: #f7f8f9; font-size: 13px; }
.share-visitor-list { display: grid; gap: 7px; }
.share-visitor-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.share-visitor-row > div { min-width: 0; display: grid; }
.share-visitor-row strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.share-visitor-row small,
.share-visitor-row > span { color: #989da5; font-size: 10px; }
.share-revoke-section p { margin-top: 0; }
.share-revoke-button { width: 100%; height: 38px; margin-top: 12px; border: 1px solid #e1e4e8; border-radius: 10px; color: #34373c; background: #f7f8f9; font-size: 13px; font-weight: 690; }
.share-revoke-button:hover { color: #a72f35; border-color: #f0c7ca; background: #fff5f5; }
.share-revoke-confirm { display: grid; grid-template-columns: 1fr 1.45fr; gap: 8px; margin-top: 12px; }
.share-revoke-confirm button { height: 36px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 9px; background: #f1f2f4; font-size: 12px; font-weight: 700; }
.share-revoke-confirm .danger { color: #fff; background: #b8343b; }
.share-loading { min-height: 150px; display: grid; place-items: center; color: #8c929b; }
@media (max-width: 520px) {
.share-popover { width: calc(100vw - 16px); max-height: calc(100vh - 72px); border-radius: 14px; }
.share-access-row { grid-template-columns: 34px minmax(0, 1fr); }
.share-access-row select { grid-column: 1 / -1; width: 100%; }
}
@@ -0,0 +1,450 @@
"use client";
import {
ArrowLeft,
AtSign,
BriefcaseBusiness,
Check,
Copy,
Globe2,
Link2,
Loader2,
MessageCircle,
Send,
Settings,
Share2,
ShieldCheck,
Trash2,
UserPlus,
Users,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { SharePermission, SharePerson, ShareSettings, ShareVisitor } from "@/domain/design";
import { sharePath } from "@/application/route";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useI18n } from "@/i18n/i18n";
import { designGateway } from "@/infrastructure/designGateway";
import { useAuth } from "@/ui/auth/AuthProvider";
import { useUserMessage } from "@/ui/hooks/useUserMessage";
import "./index.css";
type ManageablePermission = Exclude<SharePermission, "private" | "owner">;
type LinkPermission = Exclude<SharePermission, "owner">;
type SharePanel = "settings" | "invite" | "visitors";
export function SharePopover({
projectId,
projectTitle,
canManage,
onToast,
className = ""
}: {
projectId: string;
projectTitle: string;
canManage: boolean;
onToast: (message: string, variant?: "success" | "error") => void;
className?: string;
}) {
const { t } = useI18n();
const { user } = useAuth();
const toUserMessageText = useUserMessage();
const [open, setOpen] = useState(false);
const [panel, setPanel] = useState<SharePanel>("settings");
const [settings, setSettings] = useState<ShareSettings | null>(null);
const [visitors, setVisitors] = useState<ShareVisitor[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [inviteIdentifier, setInviteIdentifier] = useState("");
const [invitePermission, setInvitePermission] = useState<ManageablePermission>("viewer");
const [revokeConfirm, setRevokeConfirm] = useState(false);
useEffect(() => {
if (!open || !canManage) return;
let active = true;
setLoading(true);
designGateway
.getShareSettings(projectId)
.then(({ settings: nextSettings }) => {
if (active) setSettings(nextSettings);
})
.catch(() => onToast(t("shareSettingsLoadError"), "error"))
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [canManage, onToast, open, projectId, t]);
useEffect(() => {
if (!open) {
setPanel("settings");
setRevokeConfirm(false);
}
}, [open]);
const currentShareUrl = useMemo(() => {
if (typeof window === "undefined") return "";
if (settings?.shareId) return new URL(sharePath(settings.shareId), window.location.origin).toString();
return window.location.href;
}, [settings?.shareId]);
const ownerIdentifierKeys = useMemo(() => {
const identifiers = [user?.email ?? "", user?.phone ?? ""];
if (user?.countryCode?.trim() && user.phone?.trim()) {
const countryCode = user.countryCode.trim().replace(/^\+/, "");
const phone = user.phone.trim().replace(/^\+/, "");
identifiers.push(`+${countryCode}${phone}`);
}
return new Set(identifiers.map(normalizeShareIdentifierKey).filter(Boolean));
}, [user?.countryCode, user?.email, user?.phone]);
const memberIdentifierKeys = useMemo(
() => new Set((settings?.members ?? []).map((member) => normalizeShareIdentifierKey(member.identifier ?? "")).filter(Boolean)),
[settings?.members]
);
const replaceSettings = async (operation: () => Promise<{ settings: ShareSettings }>) => {
setBusy(true);
try {
const response = await operation();
setSettings(response.settings);
return response.settings;
} catch (error) {
onToast(toUserMessageText(error, "shareUpdateError"), "error");
return null;
} finally {
setBusy(false);
}
};
const updateLinkPermission = async (permission: LinkPermission) => {
await replaceSettings(() => designGateway.updateShareLink(projectId, permission));
};
const copyShareLink = async () => {
let nextSettings = settings;
if (canManage && !nextSettings?.shareId) {
nextSettings = await replaceSettings(() => designGateway.updateShareLink(projectId, "canvas"));
}
const url = nextSettings?.shareId && typeof window !== "undefined" ? new URL(sharePath(nextSettings.shareId), window.location.origin).toString() : currentShareUrl;
if (!url || !navigator.clipboard?.writeText) {
onToast(t("shareUnavailable"), "error");
return;
}
try {
await navigator.clipboard.writeText(url);
onToast(t("shareLinkCopied"));
} catch {
onToast(t("shareUnavailable"), "error");
}
};
const invite = async () => {
const identifier = inviteIdentifier.trim();
if (!identifier) return;
const identifierKey = normalizeShareIdentifierKey(identifier);
if (identifierKey && ownerIdentifierKeys.has(identifierKey)) {
onToast(t("messages.share.self_invite"), "error");
return;
}
if (identifierKey && memberIdentifierKeys.has(identifierKey)) {
onToast(t("messages.share.member_exists"), "error");
return;
}
const nextSettings = await replaceSettings(() => designGateway.inviteShareMember(projectId, identifier, invitePermission));
if (!nextSettings) return;
setInviteIdentifier("");
onToast(t("shareInviteAdded"));
};
const openVisitors = async () => {
setPanel("visitors");
setLoading(true);
try {
const response = await designGateway.listShareVisitors(projectId);
setVisitors(response.visitors);
} catch {
onToast(t("shareVisitorsLoadError"), "error");
} finally {
setLoading(false);
}
};
const revokeAll = async () => {
const nextSettings = await replaceSettings(() => designGateway.revokeShareAccess(projectId));
if (!nextSettings) return;
setVisitors([]);
setRevokeConfirm(false);
setPanel("settings");
onToast(t("shareAccessRemoved"));
};
const shareExternally = (target: "email" | "linkedin" | "x" | "facebook" | "whatsapp" | "telegram") => {
const url = encodeURIComponent(currentShareUrl);
const title = encodeURIComponent(projectTitle);
const targets = {
email: `mailto:?subject=${title}&body=${url}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
x: `https://x.com/intent/post?text=${title}&url=${url}`,
facebook: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
whatsapp: `https://wa.me/?text=${title}%20${url}`,
telegram: `https://t.me/share/url?url=${url}&text=${title}`
};
window.open(targets[target], target === "email" ? "_self" : "_blank", "noopener,noreferrer");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className={`assistant-header-icon ${className}`.trim()} type="button" aria-label={t("share")} title={t("share")}>
<Share2 size={16} />
</button>
</PopoverTrigger>
<PopoverContent className="share-popover" align="end" sideOffset={10} data-testid="session-share-popover">
{panel === "settings" && (
<>
<ShareHeader
title={t("shareDesign")}
trailing={
canManage ? (
<>
<button className="share-header-visitors" type="button" onClick={() => void openVisitors()}>
<Users size={15} />
<span>{t("shareVisitorCount", { count: settings?.visitorCount ?? 0 })}</span>
</button>
<button type="button" aria-label={t("settings")} title={t("settings")} onClick={() => setPanel("invite")}>
<Settings size={16} />
</button>
</>
) : undefined
}
/>
{loading ? (
<ShareLoading />
) : canManage ? (
<>
<section className="share-section share-people-section">
<h3>{t("sharePeopleWithAccess")}</h3>
<button className="share-add-person" type="button" onClick={() => setPanel("invite")}>
<UserPlus size={17} />
<span>{t("shareAddPeople")}</span>
</button>
<div className="share-avatar-stack" aria-label={t("sharePeopleWithAccess")}>
{settings?.owner && <ShareAvatar person={settings.owner} />}
{settings?.members.slice(0, 5).map((member) => <ShareAvatar key={member.id} person={member} />)}
{(settings?.members.length ?? 0) > 5 && <span className="share-avatar-more">+{(settings?.members.length ?? 0) - 5}</span>}
<button type="button" aria-label={t("shareAddPeople")} onClick={() => setPanel("invite")}>
<UserPlus size={16} />
</button>
</div>
</section>
<section className="share-section">
<h3>{t("shareAccessLevel")}</h3>
<div className="share-access-row">
<span className="share-access-icon"><Globe2 size={16} /></span>
<div className="share-audience-label">
<span>{t("shareAnyoneWithLink")}</span>
<small>{permissionHint(settings?.linkPermission ?? "private", t)}</small>
</div>
<select
aria-label={t("shareLinkPermission")}
value={settings?.linkPermission ?? "private"}
disabled={busy}
onChange={(event) => void updateLinkPermission(event.target.value as LinkPermission)}
>
<option value="private">{t("sharePrivate")}</option>
<option value="canvas">{t("shareCanvasOnly")}</option>
<option value="viewer">{t("shareCanView")}</option>
<option value="editor">{t("shareCanEdit")}</option>
</select>
</div>
</section>
</>
) : (
<section className="share-section share-viewer-summary">
<span className="share-access-icon"><ShieldCheck size={17} /></span>
<div>
<strong>{t("shareReadOnlyLink")}</strong>
<p>{t("shareViewerCopyHint")}</p>
</div>
</section>
)}
<section className="share-section share-actions-section">
<button className="share-copy-button" type="button" disabled={busy} onClick={() => void copyShareLink()} data-testid="session-share-copy-link">
{busy ? <Loader2 className="spin" size={17} /> : <Link2 size={17} />}
<span>{t("shareCopyLink")}</span>
</button>
<div className="share-social-grid">
<ShareTarget disabled={canManage && !settings?.shareId} icon={<AtSign size={17} />} label={t("shareEmail")} onClick={() => shareExternally("email")} />
<ShareTarget disabled={canManage && !settings?.shareId} icon={<BriefcaseBusiness size={17} />} label="LinkedIn" onClick={() => shareExternally("linkedin")} />
<ShareTarget disabled={canManage && !settings?.shareId} icon={<X size={17} />} label="X" onClick={() => shareExternally("x")} />
<ShareTarget disabled={canManage && !settings?.shareId} icon={<Users size={17} />} label="Facebook" onClick={() => shareExternally("facebook")} />
<ShareTarget disabled={canManage && !settings?.shareId} icon={<MessageCircle size={17} />} label="WhatsApp" onClick={() => shareExternally("whatsapp")} />
<ShareTarget disabled={canManage && !settings?.shareId} icon={<Send size={17} />} label="Telegram" onClick={() => shareExternally("telegram")} />
</div>
</section>
</>
)}
{panel === "invite" && (
<>
<ShareHeader title={t("shareDesign")} onBack={() => setPanel("settings")} />
<section className="share-section share-invite-section">
<label className="share-invite-input">
<AtSign size={16} />
<input
value={inviteIdentifier}
placeholder={t("shareInvitePlaceholder")}
autoFocus
onChange={(event) => setInviteIdentifier(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void invite();
}}
/>
</label>
<div className="share-invite-controls">
<select value={invitePermission} onChange={(event) => setInvitePermission(event.target.value as ManageablePermission)}>
<option value="canvas">{t("shareCanvasOnly")}</option>
<option value="viewer">{t("shareCanView")}</option>
<option value="editor">{t("shareCanEdit")}</option>
</select>
<button type="button" disabled={busy || !inviteIdentifier.trim()} onClick={() => void invite()}>
{busy ? <Loader2 className="spin" size={15} /> : t("shareInvite")}
</button>
</div>
<p>{permissionHint(invitePermission, t)}</p>
<div className="share-member-list">
{settings?.owner && <ShareMemberRow person={settings.owner} owner />}
{settings?.members.map((member) => (
<ShareMemberRow
key={member.id}
person={member}
disabled={busy}
onPermissionChange={(permission) => void replaceSettings(() => designGateway.updateShareMember(projectId, member.id, permission))}
onRemove={() => void replaceSettings(() => designGateway.deleteShareMember(projectId, member.id))}
/>
))}
</div>
</section>
</>
)}
{panel === "visitors" && (
<>
<ShareHeader title={t("shareVisitors")} onBack={() => setPanel("settings")} />
<section className="share-section share-visitors-section">
{loading ? (
<ShareLoading />
) : visitors.length === 0 ? (
<div className="share-empty-state">{t("shareNoVisitors")}</div>
) : (
<div className="share-visitor-list">
{visitors.map((visitor) => (
<div className="share-visitor-row" key={visitor.id}>
<ShareAvatar person={{ id: visitor.id, name: visitor.name, identifier: visitor.identifier, avatarUrl: visitor.avatarUrl, permission: "viewer" }} />
<div><strong>{visitor.name}</strong><small>{visitor.identifier || t("shareAnonymousVisitor")}</small></div>
<span>×{visitor.visitCount}</span>
</div>
))}
</div>
)}
</section>
<section className="share-section share-revoke-section">
<h3>{t("shareRemoveAllTitle")}</h3>
<p>{t("shareRemoveAllDescription")}</p>
{revokeConfirm ? (
<div className="share-revoke-confirm">
<button type="button" onClick={() => setRevokeConfirm(false)}>{t("cancel")}</button>
<button className="danger" type="button" disabled={busy} onClick={() => void revokeAll()}>
{busy ? <Loader2 className="spin" size={15} /> : <><Trash2 size={15} />{t("shareConfirmRemove")}</>}
</button>
</div>
) : (
<button className="share-revoke-button" type="button" onClick={() => setRevokeConfirm(true)} data-testid="session-share-remove-access">
{t("shareRemoveAccess")}
</button>
)}
</section>
</>
)}
</PopoverContent>
</Popover>
);
}
function ShareHeader({ title, onBack, trailing }: { title: string; onBack?: () => void; trailing?: React.ReactNode }) {
return (
<header className="share-popover-header">
<div>
{onBack && <button type="button" aria-label="Back" onClick={onBack}><ArrowLeft size={17} /></button>}
<h2>{title}</h2>
</div>
{trailing && <div className="share-header-trailing">{trailing}</div>}
</header>
);
}
function ShareLoading() {
return <div className="share-loading"><Loader2 className="spin" size={20} /></div>;
}
function ShareAvatar({ person }: { person: SharePerson }) {
const label = person.name || person.identifier || "?";
const initials = Array.from(label.trim()).slice(0, 2).join("").toUpperCase();
return <span className="share-avatar" title={label}>{person.avatarUrl ? <img src={person.avatarUrl} alt="" /> : initials}</span>;
}
function ShareMemberRow({
person,
owner = false,
disabled = false,
onPermissionChange,
onRemove
}: {
person: SharePerson;
owner?: boolean;
disabled?: boolean;
onPermissionChange?: (permission: ManageablePermission) => void;
onRemove?: () => void;
}) {
const { t } = useI18n();
return (
<div className="share-member-row">
<ShareAvatar person={person} />
<div><strong>{person.name || person.identifier}</strong><small>{person.identifier}</small></div>
{owner ? (
<span className="share-owner-label">{t("shareOwner")}</span>
) : (
<>
<select value={person.permission} disabled={disabled} onChange={(event) => onPermissionChange?.(event.target.value as ManageablePermission)}>
<option value="canvas">{t("shareCanvasOnly")}</option>
<option value="viewer">{t("shareCanView")}</option>
<option value="editor">{t("shareCanEdit")}</option>
</select>
<button className="share-member-remove" type="button" disabled={disabled} aria-label={t("remove")} onClick={onRemove}><X size={15} /></button>
</>
)}
</div>
);
}
function ShareTarget({ icon, label, disabled = false, onClick }: { icon: React.ReactNode; label: string; disabled?: boolean; onClick: () => void }) {
return <button type="button" disabled={disabled} onClick={onClick}>{icon}<span>{label}</span></button>;
}
function permissionHint(permission: LinkPermission | ManageablePermission, t: (key: string) => string) {
if (permission === "editor") return t("shareEditorHint");
if (permission === "viewer") return t("shareViewerHint");
if (permission === "canvas") return t("shareCanvasHint");
return t("sharePrivateHint");
}
function normalizeShareIdentifierKey(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.includes("@")) return `email:${trimmed.toLowerCase()}`;
const phone = trimmed.replace(/[\s\-()]/g, "");
return /^\+?[0-9]{6,20}$/.test(phone) ? `phone:${phone}` : "";
}
@@ -5,6 +5,17 @@
gap: 6px;
}
.workspace-title.is-disabled .workspace-title-name,
.workspace-title.is-disabled .title-ghost {
color: #a7adb5;
cursor: default;
}
.workspace-title.is-disabled .workspace-title-name:hover,
.workspace-title.is-disabled .title-ghost:hover {
background: transparent;
}
.moteva-mark {
width: 1.8rem;
height: 1.8rem;
@@ -10,6 +10,7 @@ type WorkspaceTitleProps = {
backHomeLabel: string;
copyLabel: string;
moreLabel: string;
disabled?: boolean;
onBackHome: () => void;
onRename: (title: string) => Promise<void>;
};
@@ -22,6 +23,7 @@ export function WorkspaceTitle({
backHomeLabel,
copyLabel,
moreLabel,
disabled = false,
onBackHome,
onRename
}: WorkspaceTitleProps) {
@@ -37,6 +39,10 @@ export function WorkspaceTitle({
if (!editing) setDraftTitle(title);
}, [editing, title]);
useEffect(() => {
if (disabled) setEditing(false);
}, [disabled]);
useEffect(() => {
if (!editing) return;
const input = inputRef.current;
@@ -46,7 +52,7 @@ export function WorkspaceTitle({
}, [editing]);
const startEditing = () => {
if (renaming) return;
if (disabled || renaming) return;
setDraftTitle(trimmedTitle);
setEditing(true);
};
@@ -57,7 +63,7 @@ export function WorkspaceTitle({
};
const commitEditing = async () => {
if (committingRef.current) return;
if (disabled || committingRef.current) return;
const nextTitle = draftTitle.trim();
if (!nextTitle || nextTitle === trimmedTitle) {
cancelEditing();
@@ -77,7 +83,7 @@ export function WorkspaceTitle({
};
return (
<div className="workspace-title">
<div className={`workspace-title ${disabled ? "is-disabled" : ""}`} aria-disabled={disabled}>
<button className="moteva-mark" type="button" onClick={onBackHome} aria-label={backHomeLabel}>
<BrandMark className="workspace-brand-mark" />
</button>
@@ -88,6 +94,7 @@ export function WorkspaceTitle({
value={draftTitle}
placeholder={placeholder}
readOnly={renaming}
disabled={disabled}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={() => void commitEditing()}
onKeyDown={(event) => {
@@ -104,14 +111,14 @@ export function WorkspaceTitle({
{renaming && <Loader2 className="spin" size={13} />}
</span>
) : (
<button className="workspace-title-name" type="button" title={displayTitle} aria-label={renameLabel} onClick={startEditing}>
<button className="workspace-title-name" type="button" title={displayTitle} aria-label={renameLabel} onClick={startEditing} disabled={disabled}>
<strong>{displayTitle}</strong>
</button>
)}
<button className="title-ghost" type="button" aria-label={copyLabel}>
<button className="title-ghost" type="button" aria-label={copyLabel} disabled={disabled}>
<Copy size={14} />
</button>
<button className="title-ghost" type="button" aria-label={moreLabel}>
<button className="title-ghost" type="button" aria-label={moreLabel} disabled={disabled}>
<ChevronDown size={14} />
</button>
</div>
@@ -13,6 +13,7 @@ export function WorkspaceToolbar({
shapeToolMenuOpen,
selectedShapeKind,
showGrid,
disabled = false,
offsetBy,
onCanvasToolMenuOpenChange,
onShapeToolMenuOpenChange,
@@ -26,6 +27,7 @@ export function WorkspaceToolbar({
shapeToolMenuOpen: boolean;
selectedShapeKind: ShapeKind;
showGrid: boolean;
disabled?: boolean;
offsetBy: "files" | "layers" | null;
onCanvasToolMenuOpenChange: (open: boolean) => void;
onShapeToolMenuOpenChange: (open: boolean) => void;
@@ -37,6 +39,7 @@ export function WorkspaceToolbar({
const { t } = useI18n();
const offsetClass = offsetBy === "files" ? "is-offset-by-files-panel" : offsetBy === "layers" ? "is-offset-by-layers-panel" : "";
const choosePrimaryCanvasTool = () => {
if (disabled) return;
if (activeTool !== "select") {
onChooseTool("select");
onCanvasToolMenuOpenChange(false);
@@ -46,8 +49,8 @@ export function WorkspaceToolbar({
};
return (
<div className={`workspace-toolbar ${offsetClass}`}>
<Popover open={canvasToolMenuOpen} onOpenChange={onCanvasToolMenuOpenChange}>
<div className={`workspace-toolbar ${offsetClass} ${disabled ? "is-disabled" : ""}`} aria-disabled={disabled} inert={disabled}>
<Popover open={!disabled && canvasToolMenuOpen} onOpenChange={(open) => !disabled && onCanvasToolMenuOpenChange(open)}>
<PopoverAnchor asChild>
<button
type="button"
@@ -56,6 +59,7 @@ export function WorkspaceToolbar({
aria-expanded={canvasToolMenuOpen}
title={activeTool === "pan" ? t("pan") : t("select")}
onClick={choosePrimaryCanvasTool}
disabled={disabled}
>
{activeTool === "pan" ? <Hand size={18} /> : <MousePointer2 size={18} />}
</button>
@@ -73,16 +77,16 @@ export function WorkspaceToolbar({
</button>
</PopoverContent>
</Popover>
<button className={activeTool === "pin" ? "active" : ""} onClick={() => onChooseTool("pin")} aria-label={t("pin")} title={t("pin")}>
<button disabled={disabled} className={activeTool === "pin" ? "active" : ""} onClick={() => onChooseTool("pin")} aria-label={t("pin")} title={t("pin")}>
<MapPin size={18} />
</button>
<button aria-label={t("uploadImageNode")} title={t("uploadImageNode")} onClick={onUploadImage}>
<button disabled={disabled} aria-label={t("uploadImageNode")} title={t("uploadImageNode")} onClick={onUploadImage}>
<ImageUp size={18} />
</button>
<button className={showGrid ? "active" : ""} aria-label={t("grid")} title={t("grid")} onClick={onToggleGrid}>
<button disabled={disabled} className={showGrid ? "active" : ""} aria-label={t("grid")} title={t("grid")} onClick={onToggleGrid}>
<Grid3X3 size={18} />
</button>
<button className={activeTool === "frame" ? "active" : ""} aria-label={t("smartArtboard")} title={`${t("smartArtboard")} F`} onClick={() => onChooseTool("frame")}>
<button disabled={disabled} className={activeTool === "frame" ? "active" : ""} aria-label={t("smartArtboard")} title={`${t("smartArtboard")} F`} onClick={() => onChooseTool("frame")}>
<Hash size={18} />
</button>
<ShapeToolMenu
@@ -93,14 +97,14 @@ export function WorkspaceToolbar({
onChooseTool={onChooseTool}
onChooseShapeKind={onChooseShapeKind}
/>
<button className={activeTool === "pen" ? "active" : ""} aria-label={t("pen")} title={t("pen")} onClick={() => onChooseTool("pen")}>
<button disabled={disabled} className={activeTool === "pen" ? "active" : ""} aria-label={t("pen")} title={t("pen")} onClick={() => onChooseTool("pen")}>
<PenLine size={18} />
</button>
<button className={activeTool === "text" ? "active" : ""} aria-label={t("textNode")} title={`${t("textNode")} T`} onClick={() => onChooseTool("text")}>
<button disabled={disabled} className={activeTool === "text" ? "active" : ""} aria-label={t("textNode")} title={`${t("textNode")} T`} onClick={() => onChooseTool("text")}>
<Type size={18} />
</button>
<span className="workspace-toolbar-separator" />
<button className={activeTool === "image" ? "active" : ""} aria-label={t("imageNode")} title={t("imageNode")} onClick={() => onChooseTool("image")}>
<button disabled={disabled} className={activeTool === "image" ? "active" : ""} aria-label={t("imageNode")} title={t("imageNode")} onClick={() => onChooseTool("image")}>
<span className="toolbar-image-sparkle-icon" aria-hidden="true">
<ImageIcon size={18} />
<Sparkles size={9} />
@@ -16,6 +16,7 @@ export function useCanvasPersistence({
nodesRef,
dirty,
anyGenerating,
disabled = false,
setProject,
setDirty,
setSaving,
@@ -30,6 +31,7 @@ export function useCanvasPersistence({
nodesRef: RefObject<CanvasNode[]>;
dirty: boolean;
anyGenerating: boolean;
disabled?: boolean;
setProject: Dispatch<SetStateAction<Project | null>>;
setDirty: Dispatch<SetStateAction<boolean>>;
setSaving: Dispatch<SetStateAction<boolean>>;
@@ -50,7 +52,7 @@ export function useCanvasPersistence({
const saveCanvasSnapshot = useCallback(
async ({ quiet = false }: { quiet?: boolean } = {}) => {
const currentProject = projectRef.current;
if (!currentProject) return null;
if (!currentProject || disabled) return null;
clearScheduledSave();
if (!quiet) setSaving(true);
setError("");
@@ -73,12 +75,12 @@ export function useCanvasPersistence({
if (!quiet) setSaving(false);
}
},
[clearScheduledSave, toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef]
[clearScheduledSave, disabled, toUserMessageText, nodesRef, projectRef, setDirty, setError, setProject, setSaving, viewportRef]
);
const scheduleCanvasSave = useCallback(
({ delayMs = canvasSaveDebounceMs }: { delayMs?: number } = {}) => {
if (!projectRef.current || anyGenerating) {
if (!projectRef.current || anyGenerating || disabled) {
clearScheduledSave();
return;
}
@@ -88,13 +90,13 @@ export function useCanvasPersistence({
void saveCanvasSnapshot({ quiet: true });
}, delayMs);
},
[anyGenerating, clearScheduledSave, projectRef, saveCanvasSnapshot]
[anyGenerating, clearScheduledSave, disabled, projectRef, saveCanvasSnapshot]
);
const updateProjectTitle = useCallback(
async (nextTitle: string) => {
const currentProject = projectRef.current;
if (!currentProject) return;
if (!currentProject || disabled) return;
setError("");
try {
if (dirty && !anyGenerating) {
@@ -108,16 +110,16 @@ export function useCanvasPersistence({
throw err;
}
},
[anyGenerating, dirty, toUserMessageText, projectRef, saveCanvasSnapshot, setError, setProject]
[anyGenerating, dirty, disabled, toUserMessageText, projectRef, saveCanvasSnapshot, setError, setProject]
);
useEffect(() => {
if (!project || !dirty || anyGenerating) {
if (!project || !dirty || anyGenerating || disabled) {
clearScheduledSave();
return;
}
scheduleCanvasSave();
}, [anyGenerating, clearScheduledSave, dirty, nodes, project, scheduleCanvasSave, viewport]);
}, [anyGenerating, clearScheduledSave, dirty, disabled, nodes, project, scheduleCanvasSave, viewport]);
useEffect(() => clearScheduledSave, [clearScheduledSave]);
+71 -48
View File
@@ -1,11 +1,11 @@
"use client";
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react";
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { openHome } from "@/application/route";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design";
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { designGateway } from "@/infrastructure/designGateway";
import { httpStatusOf } from "@/infrastructure/userMessages";
@@ -29,6 +29,7 @@ import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/com
import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar";
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { SharePopover } from "@/ui/components/SharePopover";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
@@ -526,9 +527,25 @@ function cleanCanvasAvatarUrl(value: string | undefined) {
return avatarUrl;
}
export function CanvasWorkspace({ projectId }: { projectId: string }) {
const ownerWorkspaceAccess: ShareAccess = {
permission: "owner",
source: "owner",
authenticated: true,
isOwner: true,
capabilities: {
canViewCanvas: true,
canViewChat: true,
canEdit: true,
canManage: true
}
};
export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) {
const { t, locale } = useI18n();
const { isAuthenticated } = useAuth();
const canEdit = shareAccess.capabilities.canEdit;
const canViewChat = shareAccess.capabilities.canViewChat;
const canManageSharing = shareAccess.capabilities.canManage;
const toUserMessageText = useUserMessage();
const { showToast } = useGlobalToast();
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
@@ -556,7 +573,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const [prompt, setPrompt] = useState("");
const [agentThreads, setAgentThreads] = useState<AgentThreadSummary[]>([]);
const [credits, setCredits] = useState(30);
const [assistantPanelOpen, setAssistantPanelOpen] = useState(true);
const [assistantPanelOpen, setAssistantPanelOpen] = useState(canViewChat);
const [agentHistoryOpen, setAgentHistoryOpen] = useState(false);
const [agentHistorySearch, setAgentHistorySearch] = useState("");
const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null);
@@ -679,6 +696,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
.catch(() => undefined);
}, [isAuthenticated]);
useEffect(() => {
if (!canViewChat) {
setAssistantPanelOpen(false);
setAgentHistoryOpen(false);
}
}, [canViewChat]);
useEffect(() => {
webSearchHydratedRef.current = false;
const stored = window.localStorage.getItem(canvasBackgroundStorageKey(projectId));
@@ -734,6 +758,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
});
const loadAgentThreads = useCallback(async () => {
if (!canViewChat) return;
const currentProject = projectRef.current;
if (!currentProject) return;
try {
@@ -742,7 +767,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
} catch (err) {
console.warn("Failed to load agent thread history", err);
}
}, [projectRef]);
}, [canViewChat, projectRef]);
const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({
project,
@@ -753,6 +778,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
nodesRef,
dirty,
anyGenerating,
disabled: !canEdit,
setProject,
setDirty,
setSaving,
@@ -825,7 +851,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject));
webSearchHydratedRef.current = true;
}
if (isProjectGenerating(nextProject)) {
if (isProjectGenerating(nextProject) && canViewChat) {
const channel: GenerationChannel = projectHasImageGeneratorGeneration(nextProject) ? "image-generator" : projectHasCanvasActionGeneration(nextProject) ? "canvas-action" : "agent";
if (channel === "image-generator") {
setImageGeneratorGenerating(true);
@@ -840,7 +866,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
startProjectEvents(nextProject.id, nextProject.lastThreadId, channel);
}
}
return designGateway.getProjectHistory(nextProject.id);
return canViewChat ? designGateway.getProjectHistory(nextProject.id) : null;
})
.then((history) => {
if (history && !agentStreamingProjectIdRef.current) {
@@ -867,7 +893,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}
setError(toUserMessageText(err, "generateError"));
});
}, [applyProject, projectId, startProjectEvents]);
}, [applyProject, canViewChat, projectId, startProjectEvents]);
useEffect(() => {
referenceImagesRef.current = referenceImages;
@@ -920,7 +946,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}, [agentHistorySearch, agentHistoryThreads]);
const selectedCanvasModel = imageModels.find((model) => model.id === selectedCanvasModelId) ?? imageModels[0];
const activeHistoryThreadId = selectedAgentThreadId === newAgentConversationId ? newAgentConversationId : selectedAgentThreadId || agentHistoryThreads[0]?.agentThreadId || "";
const selectedNodeControlsEnabled = selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
const selectedNodeControlsEnabled = canEdit && selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null;
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
@@ -1234,9 +1260,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
};
const showCanvasToast = (message: string, tone: "success" | "error" = "success") => {
const showCanvasToast = useCallback((message: string, tone: "success" | "error" = "success") => {
showToast(message, tone);
};
}, [showToast]);
const attachFrameChildren = (current: CanvasNode[], candidateIds: Set<string>) => attachNodesToContainingFrames(current, candidateIds);
@@ -2278,6 +2304,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableShortcutTarget(event.target)) return;
if (!canEdit) return;
const key = event.key.toLowerCase();
const primary = isPrimaryShortcut(event);
@@ -2343,6 +2370,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (isEditableShortcutTarget(event.target)) return;
if (!canEdit) return;
if (!event.clipboardData) return;
if (pasteFromClipboardData(event.clipboardData)) {
event.preventDefault();
@@ -2496,6 +2524,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
const chooseCanvasTool = (tool: CanvasTool) => {
if (!canEdit) return;
setActiveTool(tool);
setCanvasToolMenuOpen(false);
if (tool !== "shape") setShapeToolMenuOpen(false);
@@ -2532,24 +2561,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
chooseCanvasTool("shape");
};
const shareProject = () => {
if (!project) return;
const shareUrl = window.location.href;
const shareTitle = project.title || t("canvasUntitled");
if (navigator.share) {
void navigator.share({ title: shareTitle, url: shareUrl }).catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
showCanvasToast(t("shareUnavailable"), "error");
});
return;
}
if (!navigator.clipboard?.writeText) {
showCanvasToast(t("shareUnavailable"), "error");
return;
}
void navigator.clipboard.writeText(shareUrl).then(() => showCanvasToast(t("shareLinkCopied"))).catch(() => showCanvasToast(t("shareUnavailable"), "error"));
};
const syncReferenceImages = (references: UploadedReferenceImage[]) => {
referenceImagesRef.current = references;
setReferenceImages(references);
@@ -3415,7 +3426,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}
return (
<div className={`workspace-shell ${assistantPanelOpen ? "" : "assistant-panel-collapsed"}`}>
<div className={`workspace-shell ${assistantPanelOpen && canViewChat ? "" : "assistant-panel-collapsed"} ${canEdit ? "" : "is-readonly"} ${canViewChat ? "" : "is-canvas-only"}`}>
<header className="workspace-topbar">
<div className="workspace-countdown" aria-label={t("promo")}>
<span>0</span>
@@ -3442,13 +3453,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
ref={containerRef}
onWheel={handleWheel}
onPointerDown={handleBackgroundPointerDown}
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
onPointerMove={handleCanvasPointerMove}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onDragOver={canEdit ? handleCanvasDragOver : undefined}
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
onDrop={canEdit ? handleCanvasDrop : undefined}
>
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden onChange={handleCanvasImageInput} />
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden disabled={!canEdit} onChange={handleCanvasImageInput} />
<div className="workspace-stage-title">
<WorkspaceTitle
title={project.title}
@@ -3458,14 +3469,24 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
backHomeLabel={t("backHome")}
copyLabel={t("copyProject")}
moreLabel={t("more")}
disabled={!canEdit}
onBackHome={openHome}
onRename={updateProjectTitle}
/>
</div>
{assistantPanelOpen ? (
<CanvasAccountControls credits={credits} />
{canViewChat ? (
assistantPanelOpen ? (
<CanvasAccountControls credits={credits} />
) : (
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
)
) : (
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
<CanvasAccountControls credits={credits} />
)}
{!canViewChat && (
<div className="canvas-share-control">
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
</div>
)}
{layersPanelOpen && (
<CanvasLayerPanel
@@ -3499,7 +3520,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
{renderedCanvasNodes.map((node) => {
const hidden = hiddenNodeIds.has(node.id);
const locked = lockedNodeIds.has(node.id);
const canOperateNode = !locked;
const canOperateNode = canEdit && !locked;
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id;
const imageGeneratorTargetSize =
node.type === "image" && node.layerRole === "image-generator"
@@ -3829,6 +3850,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
/>
)}
{!canEdit && <div className="workspace-readonly-shield" aria-hidden="true" />}
<div className={`canvas-bottom-left ${generatedFilesPanelOpen ? "is-offset-by-files-panel" : layersPanelOpen ? "is-offset-by-layers-panel" : ""}`}>
{showMinimap && (
<CanvasMinimap
@@ -3844,7 +3867,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onViewportCommit={scheduleCanvasSave}
/>
)}
<div className="canvas-statusbar">
<div className={`canvas-statusbar ${canEdit ? "" : "is-disabled"}`} aria-disabled={!canEdit} inert={!canEdit}>
<CanvasColorPopover
title={t("canvasBackgroundColor")}
value={canvasBackgroundColor}
@@ -3904,6 +3927,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
shapeToolMenuOpen={shapeToolMenuOpen}
selectedShapeKind={selectedShapeKind}
showGrid={showGrid}
disabled={!canEdit}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
onShapeToolMenuOpenChange={setShapeToolMenuOpen}
@@ -3912,7 +3936,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onToggleGrid={() => setShowGrid((value) => !value)}
onUploadImage={() => canvasImageInputRef.current?.click()}
/>
{activeTool === "pen" && (
{canEdit && activeTool === "pen" && (
<PenToolControls
settings={penSettings}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
@@ -3921,14 +3945,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
)}
</div>
{assistantPanelOpen && (
{canViewChat && assistantPanelOpen && (
<aside className="assistant-panel">
<div className="assistant-header">
<div className="assistant-header-title">
<strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
</div>
<div className="assistant-header-actions">
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!canEdit || !hasCurrentConversation}>
<MessageSquarePlus size={16} />
</button>
<Popover open={agentHistoryOpen} onOpenChange={setAgentHistoryOpen}>
@@ -3949,7 +3973,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
/>
</label>
<div className="agent-history-list" data-testid="agent-history-menu">
{!agentHistorySearch.trim() && (
{canEdit && !agentHistorySearch.trim() && (
<div
className={`agent-history-item ${activeHistoryThreadId === newAgentConversationId ? "active" : ""}`}
role="button"
@@ -3985,6 +4009,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
<button
className="agent-history-delete"
type="button"
disabled={!canEdit}
aria-label={t("deleteConversation")}
title={t("deleteConversation")}
onClick={(event) => {
@@ -4002,9 +4027,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
</div>
</PopoverContent>
</Popover>
<button className="assistant-header-icon" type="button" aria-label={t("share")} title={t("share")} onClick={shareProject} disabled={!hasCurrentConversation}>
<Share2 size={16} />
</button>
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
<span className="assistant-header-separator" aria-hidden="true" />
<button className="assistant-header-icon" type="button" aria-label={t("collapse")} title={t("collapse")} onClick={() => setAssistantPanelOpen(false)}>
<PanelRightClose size={16} />
@@ -4029,7 +4052,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
</div>
)}
<div className="assistant-footer-dock" ref={assistantFooterRef}>
<div className={`assistant-footer-dock ${canEdit ? "" : "is-disabled"}`} ref={assistantFooterRef} aria-disabled={!canEdit} inert={!canEdit}>
<CanvasAgentComposer
ref={agentComposerRef}
text={prompt}
+62
View File
@@ -1295,6 +1295,21 @@ button:disabled {
grid-template-columns: minmax(0, 1fr);
}
.workspace-shell.is-canvas-only .workspace-topbar {
display: none;
}
.workspace-shell.is-canvas-only .workspace-main {
height: 100vh;
margin-top: 0;
}
@supports (height: 100dvh) {
.workspace-shell.is-canvas-only .workspace-main {
height: 100dvh;
}
}
.workspace-stage-title {
position: absolute;
top: 0;
@@ -1334,6 +1349,20 @@ button:disabled {
background: transparent;
}
.canvas-share-control {
position: absolute;
z-index: 46;
top: 11px;
right: 58px;
}
.canvas-share-control .assistant-header-icon {
border: 1px solid rgba(27, 31, 37, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.07);
backdrop-filter: blur(12px);
}
.canvas-collapsed-credit {
height: 24px;
display: inline-flex;
@@ -1611,6 +1640,14 @@ button:disabled {
transform-origin: 0 0;
}
.workspace-readonly-shield {
position: absolute;
inset: 0;
z-index: 39;
cursor: default;
background: transparent;
}
.canvas-node {
position: absolute;
overflow: hidden;
@@ -3027,6 +3064,24 @@ button:disabled {
background: #303030;
}
.canvas-statusbar.is-disabled,
.workspace-toolbar.is-disabled {
opacity: 0.46;
filter: grayscale(0.45);
pointer-events: none;
}
.workspace-toolbar.is-disabled button,
.workspace-toolbar.is-disabled button:hover,
.workspace-toolbar.is-disabled button.active,
.canvas-statusbar.is-disabled button,
.canvas-statusbar.is-disabled button:hover,
.canvas-statusbar.is-disabled button.active {
color: #9299a3;
background: transparent;
cursor: default;
}
.canvas-statusbar span {
min-width: 34px;
text-align: center;
@@ -3527,6 +3582,13 @@ button:disabled {
background: rgba(82, 109, 135, 0.15);
}
.assistant-footer-dock.is-disabled {
opacity: 0.48;
filter: grayscale(0.55);
pointer-events: none;
user-select: none;
}
.assistant-history-trigger {
width: 18px;
min-width: 18px;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"}
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/layout.tsx","./src/app/page.tsx","./src/app/root.tsx","./src/app/[...path]/page.tsx","./src/app/api/[...path]/route.ts","./src/app/project/[id]/page.tsx","./src/app/projects/page.tsx","./src/app/projects/[id]/page.tsx","./src/application/route.ts","./src/components/ui/button.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/domain/auth.ts","./src/domain/design.ts","./src/i18n/i18n.tsx","./src/infrastructure/authgateway.ts","./src/infrastructure/designgateway.ts","./src/infrastructure/imageuploadencoding.ts","./src/infrastructure/usermessages.ts","./src/ui/app.tsx","./src/ui/auth/authprovider/index.tsx","./src/ui/auth/logindialog/brandlogo.tsx","./src/ui/auth/logindialog/otpinput.tsx","./src/ui/auth/logindialog/turnstilewidget.tsx","./src/ui/auth/logindialog/index.tsx","./src/ui/components/brandmark.tsx","./src/ui/components/accountmanagementdialog/index.tsx","./src/ui/components/artboardpreview/index.tsx","./src/ui/components/canvasagentcomposer/index.tsx","./src/ui/components/canvasagenttimeline/index.tsx","./src/ui/components/canvasannotationpreview/index.tsx","./src/ui/components/canvasannotations/index.tsx","./src/ui/components/canvasminimap/index.tsx","./src/ui/components/canvasnodecontextmenu/index.tsx","./src/ui/components/canvaspanels/index.tsx","./src/ui/components/canvastoast/index.tsx","./src/ui/components/canvasworkoverlay/index.tsx","./src/ui/components/canvaszoommenu/index.tsx","./src/ui/components/deleteprojectdialog/index.tsx","./src/ui/components/imagegeneratorfloatingcomposer/index.tsx","./src/ui/components/imagenodeactions/index.tsx","./src/ui/components/imagetexteditpanel/index.tsx","./src/ui/components/layermergeskeleton/index.tsx","./src/ui/components/markdowncontent/index.tsx","./src/ui/components/multianglepanel/index.tsx","./src/ui/components/multiselectionoverlay/index.tsx","./src/ui/components/projectcard/index.tsx","./src/ui/components/projectthumb/index.tsx","./src/ui/components/promptcomposer/index.tsx","./src/ui/components/sharepopover/index.tsx","./src/ui/components/textstyletoolbar/fontcatalog.ts","./src/ui/components/textstyletoolbar/index.tsx","./src/ui/components/toolbarcustomizedialog/index.tsx","./src/ui/components/workspacetitle/index.tsx","./src/ui/hooks/usefloatingtoolbarplacement.ts","./src/ui/hooks/usetypewritertext.ts","./src/ui/hooks/useusermessage.ts","./src/ui/lib/friendlyagenterrors.ts","./src/ui/lib/imageadjustments.ts","./src/ui/lib/imagedelivery.ts","./src/ui/lib/promptimagereferences.ts","./src/ui/pages/canvasworkspace/index.tsx","./src/ui/pages/canvasworkspace/canvas/canvascolorpopover.tsx","./src/ui/pages/canvasworkspace/canvas/framestyletoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/frametooloverlay.tsx","./src/ui/pages/canvasworkspace/canvas/penstroketoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/pentool.tsx","./src/ui/pages/canvasworkspace/canvas/workspacetoolbar.tsx","./src/ui/pages/canvasworkspace/canvas/agentmessagefilters.ts","./src/ui/pages/canvasworkspace/canvas/canvasgeometry.ts","./src/ui/pages/canvasworkspace/canvas/canvasnodeops.ts","./src/ui/pages/canvasworkspace/canvas/framestyle.ts","./src/ui/pages/canvasworkspace/canvas/generatortaskpolling.ts","./src/ui/pages/canvasworkspace/canvas/imagegeneratorsizing.ts","./src/ui/pages/canvasworkspace/canvas/nodeexport.ts","./src/ui/pages/canvasworkspace/canvas/nodereferenceimages.ts","./src/ui/pages/canvasworkspace/canvas/shapenode.ts","./src/ui/pages/canvasworkspace/canvas/textnodedefaults.ts","./src/ui/pages/canvasworkspace/canvas/types.ts","./src/ui/pages/canvasworkspace/canvas/canvasselectionframe/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapenodepreview/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapestyletoolbar/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetoolmenu/index.tsx","./src/ui/pages/canvasworkspace/canvas/shapetooloverlay/index.tsx","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasdocument.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvaspersistence.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usecanvasselection.ts","./src/ui/pages/canvasworkspace/canvas/hooks/usegenerationstream.ts","./src/ui/pages/homepage/index.tsx","./src/ui/pages/homeshell/index.tsx","./src/ui/pages/notfoundpage/index.tsx","./src/ui/pages/projectspage/index.tsx","./src/ui/pages/projectsroutepage/index.tsx"],"version":"6.0.3"}