УЛУЧШЕНИЕ - NODEDC LAUNCHER: полные ссылки инвайтов
This commit is contained in:
parent
bd1575d18a
commit
821a4009f0
|
|
@ -2904,7 +2904,7 @@ code {
|
|||
|
||||
.invite-link-cell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
|
@ -2917,6 +2917,14 @@ code {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-link-cell__state {
|
||||
color: var(--accent-lime);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-helper-note {
|
||||
max-width: 38rem;
|
||||
margin: 0.22rem 0 0;
|
||||
|
|
|
|||
|
|
@ -2090,6 +2090,7 @@ function InvitesSection({
|
|||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<ClientMembershipRole>("member");
|
||||
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
const invites = data.invites.filter((invite) => invite.clientId === clientId);
|
||||
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
|
||||
const actor = getUser(data, actorUserId);
|
||||
|
|
@ -2102,6 +2103,26 @@ function InvitesSection({
|
|||
setRole("member");
|
||||
}
|
||||
|
||||
async function handleCopyInvite(invite: Invite) {
|
||||
const inviteUrl = buildInviteUrl(invite.token);
|
||||
|
||||
try {
|
||||
await copyToClipboard(inviteUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
setCopiedInviteId(invite.id);
|
||||
|
||||
if (invite.status === "created") {
|
||||
onUpdateInvite(invite.id, { status: "sent" });
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
setCopiedInviteId((currentInviteId) => (currentInviteId === invite.id ? null : currentInviteId));
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invites-layout invites-layout--catalog">
|
||||
<GlassSurface className="invite-form invite-form--compact">
|
||||
|
|
@ -2149,69 +2170,75 @@ function InvitesSection({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id}>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={invite.email}
|
||||
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
||||
aria-label={`Email инвайта ${invite.email}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={invite.role}
|
||||
options={inviteRoleOptions}
|
||||
label={`Роль инвайта ${invite.email}`}
|
||||
minMenuWidth={172}
|
||||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
value={invite.status}
|
||||
options={inviteStatusOptions}
|
||||
label={`Статус инвайта ${invite.email}`}
|
||||
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="invite-link-cell">
|
||||
<code>{`/invite/${invite.token}`}</code>
|
||||
{invites.map((invite) => {
|
||||
const inviteUrl = buildInviteUrl(invite.token);
|
||||
const isCopied = copiedInviteId === invite.id;
|
||||
|
||||
return (
|
||||
<tr key={invite.id}>
|
||||
<td>
|
||||
<input
|
||||
className="admin-table-input admin-table-input--strong"
|
||||
value={invite.email}
|
||||
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
|
||||
aria-label={`Email инвайта ${invite.email}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcSelect
|
||||
className="admin-table-select-wrap"
|
||||
triggerClassName="admin-table-select-trigger"
|
||||
value={invite.role}
|
||||
options={inviteRoleOptions}
|
||||
label={`Роль инвайта ${invite.email}`}
|
||||
minMenuWidth={172}
|
||||
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusDropdown
|
||||
value={invite.status}
|
||||
options={inviteStatusOptions}
|
||||
label={`Статус инвайта ${invite.email}`}
|
||||
onChange={(status) => onUpdateInvite(invite.id, { status })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="invite-link-cell">
|
||||
<code title={inviteUrl}>{inviteUrl}</code>
|
||||
{isCopied ? <span className="invite-link-cell__state">Скопировано</span> : null}
|
||||
<IconButton
|
||||
label={isCopied ? `Инвайт ${invite.email} скопирован` : `Скопировать инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => void handleCopyInvite(invite)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Скопировать инвайт ${invite.email}`}
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(`/invite/${invite.token}`)}
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NodeDcDateField
|
||||
value={invite.expiresAt}
|
||||
label={`Инвайт истекает ${invite.email}`}
|
||||
onChange={(value) => {
|
||||
if (value) onUpdateInvite(invite.id, { expiresAt: value });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="services-admin-table__actions">
|
||||
<IconButton
|
||||
label={`Удалить инвайт ${invite.email}`}
|
||||
className="admin-circle-action services-admin-table__edit"
|
||||
type="button"
|
||||
onClick={() => setDeleteInviteId(invite.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassSurface>
|
||||
|
|
@ -2233,6 +2260,38 @@ function InvitesSection({
|
|||
);
|
||||
}
|
||||
|
||||
function buildInviteUrl(token: string) {
|
||||
if (typeof window === "undefined") return `/invite/${token}`;
|
||||
|
||||
return new URL(`/invite/${token}`, window.location.origin).toString();
|
||||
}
|
||||
|
||||
async function copyToClipboard(value: string) {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
throw new Error("Clipboard API is not available");
|
||||
}
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
input.value = value;
|
||||
input.setAttribute("readonly", "");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
|
||||
const copied = document.execCommand("copy");
|
||||
input.remove();
|
||||
|
||||
if (!copied) {
|
||||
throw new Error("Fallback copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
function SyncSection({
|
||||
data,
|
||||
clientId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue