feat(canvas): add shared workspace access and controls

This commit is contained in:
2026-07-10 14:47:19 +08:00
parent f288eb1365
commit 14eaece0ac
18 changed files with 1149 additions and 80 deletions
@@ -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>