feat(canvas): add shared workspace access and controls
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user