@@ -0,0 +1,246 @@
import { useEffect , useRef , useState } from "react" ;
import { Button , IconButton } from "./Button.js" ;
import { Icon } from "./Icon.js" ;
import { Select , type SelectOption } from "./Select.js" ;
import { TextField } from "./Field.js" ;
import { Window , WindowFooterActions } from "./Window.js" ;
export interface ShareAccessMember < Role extends string = string > {
id : string ;
name : string ;
email : string ;
role : Role ;
roleLabel? : string ;
avatarUrl? : string ;
immutable? : boolean ;
}
export interface ShareAccessModalProps < Role extends string = string > {
open : boolean ;
resourceName : string ;
members : readonly ShareAccessMember < Role > [ ] ;
roleOptions : Array < SelectOption < Role > > ;
inviteEmail : string ;
inviteRole : Role ;
canManage? : boolean ;
pending? : boolean ;
message? : string ;
messageTone ? : "neutral" | "success" | "danger" | "accent" ;
onInviteEmailChange : ( email : string ) = > void ;
onInviteRoleChange : ( role : Role ) = > void ;
onInvite : ( ) = > void | Promise < void > ;
onMemberRoleChange ? : ( member : ShareAccessMember < Role > , role : Role ) = > void ;
onMemberRemove ? : ( member : ShareAccessMember < Role > ) = > void ;
onClose : ( ) = > void ;
}
function initials ( name : string , email : string ) {
const words = ( name || email ) . split ( /[\s._@-]+/ ) . filter ( Boolean ) ;
return ` ${ words [ 0 ] ? . [ 0 ] ? ? "N" } ${ words [ 1 ] ? . [ 0 ] ? ? words [ 0 ] ? . [ 1 ] ? ? "" } ` . toUpperCase ( ) ;
}
export function ShareAccessModal < Role extends string > ( {
open ,
resourceName ,
members ,
roleOptions ,
inviteEmail ,
inviteRole ,
canManage = true ,
pending = false ,
message ,
messageTone = "neutral" ,
onInviteEmailChange ,
onInviteRoleChange ,
onInvite ,
onMemberRoleChange ,
onMemberRemove ,
onClose ,
} : ShareAccessModalProps < Role > ) {
const [ membersOpen , setMembersOpen ] = useState ( false ) ;
const membersRootRef = useRef < HTMLElement > ( null ) ;
useEffect ( ( ) = > {
if ( ! open ) setMembersOpen ( false ) ;
} , [ open ] ) ;
useEffect ( ( ) = > {
if ( ! membersOpen ) return ;
const handlePointerDown = ( event : PointerEvent ) = > {
if ( ! ( event . target instanceof Node ) ) return ;
if ( event . target instanceof Element && event . target . closest ( ".nodedc-dropdown-surface" ) ) return ;
if ( ! membersRootRef . current ? . contains ( event . target ) ) setMembersOpen ( false ) ;
} ;
const handleKeyDown = ( event : KeyboardEvent ) = > {
if ( event . key !== "Escape" ) return ;
event . preventDefault ( ) ;
event . stopImmediatePropagation ( ) ;
setMembersOpen ( false ) ;
} ;
document . addEventListener ( "pointerdown" , handlePointerDown , true ) ;
document . addEventListener ( "keydown" , handleKeyDown , true ) ;
return ( ) = > {
document . removeEventListener ( "pointerdown" , handlePointerDown , true ) ;
document . removeEventListener ( "keydown" , handleKeyDown , true ) ;
} ;
} , [ membersOpen ] ) ;
return (
< Window
open = { open }
title = "Поделиться графом"
subtitle = { resourceName . toUpperCase ( ) }
className = "nodedc-share-access-window"
closeOnBackdrop = { false }
onClose = { onClose }
footer = {
< WindowFooterActions >
< Button onClick = { onClose } disabled = { pending } > О т м е н и т ь < / Button >
{ canManage ? (
< Button variant = "accent" onClick = { ( ) = > void onInvite ( ) } disabled = { pending || ! inviteEmail . trim ( ) } >
{ pending ? "Сохранение..." : "Поделиться" }
< / Button >
) : null }
< / WindowFooterActions >
}
>
< div className = "nodedc-share-access" >
< section ref = { membersRootRef } className = "nodedc-share-access__members" aria-label = "Пользователи с доступом" >
< span className = "nodedc-share-access__section-label" > У ж е д о б а в л е н ы < / span >
{ members . length ? (
< >
< button
type = "button"
className = "nodedc-share-access__summary"
aria-label = { ` Пользователи с доступом: ${ members . length } ` }
aria-expanded = { membersOpen }
onClick = { ( ) = > setMembersOpen ( ( current ) = > ! current ) }
>
< span className = "nodedc-share-access__summary-avatars" aria-hidden = "true" >
{ members . slice ( 0 , 3 ) . map ( ( member , index ) = > (
< span className = "nodedc-share-access__summary-avatar" style = { { left : index * 18 , zIndex : 10 + index } } key = { member . id } >
{ member . avatarUrl ? < img src = { member . avatarUrl } alt = "" / > : initials ( member . name , member . email ) }
< / span >
) ) }
< / span >
< span className = "nodedc-share-access__summary-count" > { members . length } < / span >
< / button >
< / >
) : < div className = "nodedc-share-access__empty" > П о к а н и к о м у н е о т к р ы т д о с т у п < / div > }
{ membersOpen ? (
< div className = "nodedc-share-access__member-list" >
{ members . map ( ( member ) = > (
< div className = "nodedc-share-access__member" key = { member . id } >
< span className = "nodedc-share-access__avatar" aria-hidden = "true" >
{ member . avatarUrl ? < img src = { member . avatarUrl } alt = "" / > : initials ( member . name , member . email ) }
< / span >
< span className = "nodedc-share-access__identity" >
< strong > { member . name || member . email } < / strong >
{ member . name && member . name !== member . email ? < small > { member . email } < / small > : null }
< / span >
{ canManage && ! member . immutable && onMemberRoleChange ? (
< Select
value = { member . role }
label = { ` Роль ${ member . email } ` }
options = { roleOptions }
minMenuWidth = { 150 }
onChange = { ( role ) = > onMemberRoleChange ( member , role ) }
/ >
) : < span className = "nodedc-share-access__role" > { member . roleLabel ? ? member . role } < / span > }
{ canManage && ! member . immutable && onMemberRemove ? (
< IconButton label = { ` Удалить доступ ${ member . email } ` } onClick = { ( ) = > onMemberRemove ( member ) } >
< Icon name = "trash" / >
< / IconButton >
) : null }
< / div >
) ) }
< / div >
) : null }
< / section >
{ canManage ? (
< div className = "nodedc-share-access__invite" >
< TextField
label = "Пользователь"
type = "email"
value = { inviteEmail }
disabled = { pending }
placeholder = "email@company.ru"
onChange = { ( event ) = > onInviteEmailChange ( event . target . value ) }
/ >
< div className = "nodedc-field" >
< span className = "nodedc-field__label-row" > < span className = "nodedc-field__label" > Р о л ь < / span > < / span >
< Select
value = { inviteRole }
label = "Роль доступа"
options = { roleOptions }
disabled = { pending }
onChange = { ( role ) = > onInviteRoleChange ( role ) }
/ >
< / div >
< / div >
) : (
< div className = "nodedc-share-access__readonly" >
< strong > В ы н е м о ж е т е п р и г л а с и т ь п о л ь з о в а т е л я < / strong >
< span > П о п р о с и т е в л а д е л ь ц а и з м е н и т ь в а ш у р о в е н ь д о с т у п а . < / span >
< / div >
) }
{ message ? < div className = "nodedc-share-access__message" data-tone = { messageTone } > { message } < / div > : null }
< / div >
< / Window >
) ;
}
export interface ShareLinkModalProps {
open : boolean ;
title? : string ;
link : string ;
hint? : string ;
copyLabel? : string ;
onCopy ? : ( link : string ) = > void | Promise < void > ;
onClose : ( ) = > void ;
}
export function ShareLinkModal ( {
open ,
title = "Ссылка на модель" ,
link ,
hint = "Ссылка открывает текущую модель и сохранённую точку обзора." ,
copyLabel = "Скопировать" ,
onCopy ,
onClose ,
} : ShareLinkModalProps ) {
const [ copied , setCopied ] = useState ( false ) ;
useEffect ( ( ) = > {
if ( ! open ) setCopied ( false ) ;
} , [ open ] ) ;
const copy = async ( ) = > {
if ( onCopy ) await onCopy ( link ) ;
else await navigator . clipboard . writeText ( link ) ;
setCopied ( true ) ;
} ;
return (
< Window
open = { open }
title = { title }
size = "sm"
onClose = { onClose }
footer = {
< >
< Button onClick = { onClose } > З а к р ы т ь < / Button >
< WindowFooterActions >
< Button variant = "accent" icon = { < Icon name = { copied ? "check" : "copy" } / > } onClick = { ( ) = > void copy ( ) } disabled = { ! link } >
{ copied ? "Скопировано" : copyLabel }
< / Button >
< / WindowFooterActions >
< / >
}
>
< TextField label = "Публичная ссылка" value = { link } readOnly description = { hint } / >
< / Window >
) ;
}