feat(canvas): add shared workspace access and controls
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }> = [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user