import React, { useEffect, useMemo, useState } from 'react' import { avatarSrc } from '../../utils/avatarSrc' import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react' import type { CollectionMember, CollectionRole } from '@trek/shared' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { useToast } from '../shared/Toast' import { useCollectionStore } from '../../store/collectionStore' import { useAuthStore } from '../../store/authStore' import { collectionsApi } from '../../api/collections' import { getApiErrorMessage } from '../../utils/apiError' import type { TranslationFn } from '../../types' interface ShareCollectionModalProps { isOpen: boolean onClose: () => void collectionId: number collectionName: string isOwner: boolean members: CollectionMember[] /** Called after the current (member) user successfully leaves the list. */ onAfterLeave: () => void t: TranslationFn } const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin'] function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement { const initial = (member.username || '?').charAt(0).toUpperCase() return ( {member.avatar ? ( ) : ( initial )} ) } /** * Fusion-share surface for a single list (blueprint 4.4 / 4.8). The OWNER sees the * member roster with accepted/pending status, can invite a user from * GET /:id/available-users and cancel a pending invite. A non-owner MEMBER sees the * roster read-only plus a "Leave shared list" action (the server blocks the owner * from leaving). Incoming invites are accepted/declined from the lists rail, not here. */ export default function ShareCollectionModal({ isOpen, onClose, collectionId, collectionName, isOwner, members, onAfterLeave, t, }: ShareCollectionModalProps): React.ReactElement | null { const toast = useToast() const currentUserId = useAuthStore(s => s.user?.id) const invite = useCollectionStore(s => s.invite) const cancelInvite = useCollectionStore(s => s.cancelInvite) const removeMember = useCollectionStore(s => s.removeMember) const setMemberRole = useCollectionStore(s => s.setMemberRole) const leave = useCollectionStore(s => s.leave) const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([]) const [selectedUserId, setSelectedUserId] = useState('') const [inviteRole, setInviteRole] = useState('editor') const [settingRoleId, setSettingRoleId] = useState(null) const [inviting, setInviting] = useState(false) const [cancellingId, setCancellingId] = useState(null) const [removingId, setRemovingId] = useState(null) const [confirmLeave, setConfirmLeave] = useState(false) const [leaving, setLeaving] = useState(false) // Load the invitable users whenever an owner opens the modal. useEffect(() => { if (!isOpen || !isOwner) return let cancelled = false collectionsApi.availableUsers(collectionId) .then(data => { if (!cancelled) setAvailableUsers(data.users) }) .catch(() => { if (!cancelled) setAvailableUsers([]) }) return () => { cancelled = true } }, [isOpen, isOwner, collectionId, members.length]) // Reset transient state on close. useEffect(() => { if (!isOpen) { setSelectedUserId('') setConfirmLeave(false) } }, [isOpen]) const sortedMembers = useMemo(() => { // Owner first, then accepted, then pending — alphabetised within each band. const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2) return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username)) }, [members]) if (!isOpen) return null const handleInvite = async () => { if (selectedUserId === '' || inviting) return setInviting(true) try { await invite(collectionId, Number(selectedUserId), inviteRole) toast.success(t('collections.invite.sent')) setSelectedUserId('') } catch (err) { toast.error(getApiErrorMessage(err, t('collections.invite.error'))) } finally { setInviting(false) } } const handleSetRole = async (userId: number, role: CollectionRole) => { setSettingRoleId(userId) try { await setMemberRole(collectionId, userId, role) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setSettingRoleId(null) } } const handleCancel = async (userId: number) => { if (cancellingId != null) return setCancellingId(userId) try { await cancelInvite(collectionId, userId) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setCancellingId(null) } } const handleRemove = async (userId: number) => { if (removingId != null) return setRemovingId(userId) try { await removeMember(collectionId, userId) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setRemovingId(null) } } const handleLeave = async () => { setLeaving(true) try { await leave(collectionId) toast.success(t('collections.share.left')) onAfterLeave() } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setLeaving(false) } } return (
{/* Member roster */}

{t('collections.share.members')} {sortedMembers.length}

{sortedMembers.map(member => { const isSelf = member.user_id === currentUserId const pending = member.status === 'pending' return (

{member.username} {isSelf && ({t('collections.share.you')})}

{member.email && !pending && (

{member.email}

)}
{member.is_owner ? ( {t('collections.share.owner')} ) : pending ? ( {t('collections.share.pending')} ) : isOwner ? (
handleSetRole(member.user_id, v as CollectionRole)} options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))} disabled={settingRoleId === member.user_id} />
) : ( {t(`collections.role.${member.role ?? 'editor'}`)} )} {isOwner && pending && ( )} {isOwner && !member.is_owner && member.status === 'accepted' && ( )}
) })}
{isOwner ? ( /* Owner: invite UI */

{t('collections.share.invite')}

{t('collections.share.inviteHint')}

{availableUsers.length === 0 ? (

{t('collections.share.noUsers')}

) : (
setSelectedUserId(v === '' ? '' : Number(v))} options={availableUsers.map(u => ({ value: u.id, label: u.username }))} placeholder={t('collections.share.inviteUser')} searchable />
setInviteRole(v as CollectionRole)} options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))} />
)}
) : ( /* Member: read-only roster + leave */

{t('collections.share.memberHint')}

{confirmLeave ? (

{t('collections.share.leaveConfirm')}

) : ( )}
)}
) }