Files
TREK/client/src/components/Collections/ShareCollectionModal.tsx
T
Maurice 5f9da5b72f feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)
- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).
2026-07-01 18:02:44 +02:00

267 lines
11 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react'
import { UserPlus, Loader2, Clock, Check, Crown, LogOut } from 'lucide-react'
import type { CollectionMember } 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
}
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
const initial = (member.username || '?').charAt(0).toUpperCase()
return (
<span className="w-8 h-8 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-surface-secondary text-content-secondary text-[12px] font-semibold">
{member.avatar ? (
<img src={`/uploads/avatars/${member.avatar}`} alt="" className="w-full h-full object-cover" />
) : (
initial
)}
</span>
)
}
/**
* 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 leave = useCollectionStore(s => s.leave)
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
const [inviting, setInviting] = useState(false)
const [cancellingId, setCancellingId] = useState<number | null>(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))
toast.success(t('collections.invite.sent'))
setSelectedUserId('')
} catch (err) {
toast.error(getApiErrorMessage(err, t('collections.invite.error')))
} finally {
setInviting(false)
}
}
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 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 (
<Modal
isOpen
onClose={onClose}
title={t('collections.share.titleNamed', { name: collectionName })}
size="sm"
>
<div className="flex flex-col gap-5">
{/* Member roster */}
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2">
{t('collections.share.members')}
</h3>
<div className="flex flex-col gap-1">
{sortedMembers.map(member => {
const isSelf = member.user_id === currentUserId
const pending = member.status === 'pending'
return (
<div
key={member.user_id}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg ${pending ? 'opacity-80' : ''}`}
>
<MemberAvatar member={member} />
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-content truncate flex items-center gap-1.5">
{member.username}
{isSelf && <span className="text-content-faint font-normal">({t('collections.share.you')})</span>}
</p>
{member.email && !pending && (
<p className="text-[11px] text-content-faint truncate">{member.email}</p>
)}
</div>
{member.is_owner ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-content-faint shrink-0">
<Crown size={12} /> {t('collections.share.owner')}
</span>
) : pending ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-amber-500 shrink-0">
<Clock size={12} /> {t('collections.share.pending')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-500 shrink-0">
<Check size={12} /> {t('collections.share.accepted')}
</span>
)}
{isOwner && pending && (
<button
type="button"
onClick={() => handleCancel(member.user_id)}
disabled={cancellingId === member.user_id}
className="shrink-0 text-[11px] font-medium px-2 py-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
>
{cancellingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : t('collections.share.cancel')}
</button>
)}
</div>
)
})}
</div>
</div>
{isOwner ? (
/* Owner: invite UI */
<div className="pt-1 border-t border-edge-secondary">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mt-4 mb-2">
{t('collections.share.invite')}
</h3>
<p className="text-[12px] text-content-muted mb-3">{t('collections.share.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-[12px] text-content-faint text-center py-3">{t('collections.share.noUsers')}</p>
) : (
<div className="flex items-stretch gap-2">
<div className="flex-1 min-w-0">
<CustomSelect
value={selectedUserId}
onChange={v => setSelectedUserId(v === '' ? '' : Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('collections.share.inviteUser')}
searchable
/>
</div>
<button
type="button"
onClick={handleInvite}
disabled={selectedUserId === '' || inviting}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
<span className="hidden sm:inline">{t('collections.share.sendInvite')}</span>
</button>
</div>
)}
</div>
) : (
/* Member: read-only roster + leave */
<div className="pt-1 border-t border-edge-secondary">
<p className="text-[12px] text-content-muted mt-4 mb-3">{t('collections.share.memberHint')}</p>
{confirmLeave ? (
<div className="flex flex-col gap-2.5">
<p className="text-[13px] text-content-secondary">{t('collections.share.leaveConfirm')}</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmLeave(false)}
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover transition-colors"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleLeave}
disabled={leaving}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-danger text-white text-[13px] font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{leaving ? <Loader2 size={14} className="animate-spin" /> : <LogOut size={14} />}
{t('collections.share.leave')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmLeave(true)}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-danger text-[13px] font-medium hover:bg-danger-soft transition-colors"
>
<LogOut size={14} /> {t('collections.share.leave')}
</button>
)}
</div>
)}
</div>
</Modal>
)
}