УЛУЧШЕНИЕ - NODEDC LAUNCHER: полные ссылки инвайтов

This commit is contained in:
DCCONSTRUCTIONS 2026-05-05 16:05:54 +03:00
parent bd1575d18a
commit 821a4009f0
2 changed files with 126 additions and 59 deletions

View File

@ -2904,7 +2904,7 @@ code {
.invite-link-cell { .invite-link-cell {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
} }
@ -2917,6 +2917,14 @@ code {
white-space: nowrap; 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 { .admin-helper-note {
max-width: 38rem; max-width: 38rem;
margin: 0.22rem 0 0; margin: 0.22rem 0 0;

View File

@ -2090,6 +2090,7 @@ function InvitesSection({
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [role, setRole] = useState<ClientMembershipRole>("member"); const [role, setRole] = useState<ClientMembershipRole>("member");
const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null); const [deleteInviteId, setDeleteInviteId] = useState<string | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const invites = data.invites.filter((invite) => invite.clientId === clientId); const invites = data.invites.filter((invite) => invite.clientId === clientId);
const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null; const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null;
const actor = getUser(data, actorUserId); const actor = getUser(data, actorUserId);
@ -2102,6 +2103,26 @@ function InvitesSection({
setRole("member"); 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 ( return (
<div className="invites-layout invites-layout--catalog"> <div className="invites-layout invites-layout--catalog">
<GlassSurface className="invite-form invite-form--compact"> <GlassSurface className="invite-form invite-form--compact">
@ -2149,69 +2170,75 @@ function InvitesSection({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{invites.map((invite) => ( {invites.map((invite) => {
<tr key={invite.id}> const inviteUrl = buildInviteUrl(invite.token);
<td> const isCopied = copiedInviteId === invite.id;
<input
className="admin-table-input admin-table-input--strong" return (
value={invite.email} <tr key={invite.id}>
onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })} <td>
aria-label={`Email инвайта ${invite.email}`} <input
/> className="admin-table-input admin-table-input--strong"
</td> value={invite.email}
<td> onChange={(event) => onUpdateInvite(invite.id, { email: event.target.value })}
<NodeDcSelect aria-label={`Email инвайта ${invite.email}`}
className="admin-table-select-wrap" />
triggerClassName="admin-table-select-trigger" </td>
value={invite.role} <td>
options={inviteRoleOptions} <NodeDcSelect
label={`Роль инвайта ${invite.email}`} className="admin-table-select-wrap"
minMenuWidth={172} triggerClassName="admin-table-select-trigger"
onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })} value={invite.role}
/> options={inviteRoleOptions}
</td> label={`Роль инвайта ${invite.email}`}
<td> minMenuWidth={172}
<AdminStatusDropdown onChange={(nextRole) => onUpdateInvite(invite.id, { role: nextRole })}
value={invite.status} />
options={inviteStatusOptions} </td>
label={`Статус инвайта ${invite.email}`} <td>
onChange={(status) => onUpdateInvite(invite.id, { status })} <AdminStatusDropdown
/> value={invite.status}
</td> options={inviteStatusOptions}
<td> label={`Статус инвайта ${invite.email}`}
<div className="invite-link-cell"> onChange={(status) => onUpdateInvite(invite.id, { status })}
<code>{`/invite/${invite.token}`}</code> />
</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 <IconButton
label={`Скопировать инвайт ${invite.email}`} label={`Удалить инвайт ${invite.email}`}
className="admin-circle-action services-admin-table__edit" className="admin-circle-action services-admin-table__edit"
type="button" type="button"
onClick={() => void navigator.clipboard?.writeText(`/invite/${invite.token}`)} onClick={() => setDeleteInviteId(invite.id)}
> >
<Copy size={14} /> <Trash2 size={15} />
</IconButton> </IconButton>
</div> </td>
</td> </tr>
<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>
))}
</tbody> </tbody>
</table> </table>
</GlassSurface> </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({ function SyncSection({
data, data,
clientId, clientId,