mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
451167bde3
The settings button was a bare round icon next to the trip's back pill; it is the same pill now, with the gear and the word Settings. The settings dialog opens centred again: its fixed height already keeps it from jumping between areas, so it does not need to be pinned to the top.
454 lines
21 KiB
TypeScript
454 lines
21 KiB
TypeScript
import { useId, useState, type ReactNode } from 'react'
|
|
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
import { Tooltip } from '../shared/Tooltip'
|
|
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
|
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
|
import { LinkedJournalIcon } from './LinkedJournalIcon'
|
|
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
|
import { TripMemberAvatar } from './TripMemberAvatar'
|
|
import { useTripJournalSectionVisible } from './useTripJournalLink'
|
|
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
|
|
|
interface TripShareDialogProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
tripId: number
|
|
tripTitle: string
|
|
onMembersChanged?: () => void
|
|
}
|
|
|
|
/** A question the dialog asks before it acts, answered in a ConfirmDialog over it. */
|
|
interface Ask {
|
|
title: string
|
|
message: string
|
|
confirmLabel: string
|
|
run: () => void
|
|
}
|
|
|
|
const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted'
|
|
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
|
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
|
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
|
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
|
|
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
|
|
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
|
|
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
|
|
|
/**
|
|
* The trip's Settings on the desktop: who is on the trip, its guests, the two
|
|
* links that let others in, and the journal the trip shows.
|
|
*/
|
|
export default function TripShareDialog(props: TripShareDialogProps) {
|
|
return props.isOpen ? <OpenDialog {...props} /> : null
|
|
}
|
|
|
|
type Area = 'people' | 'sharing' | 'journal'
|
|
|
|
/** The areas of the dialog, each with its icon, its name and the line under the name. */
|
|
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
|
|
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
|
|
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
|
|
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
|
|
]
|
|
|
|
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
|
const { t } = useTranslation()
|
|
const labelId = useId()
|
|
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
|
const [ask, setAsk] = useState<Ask | null>(null)
|
|
const [area, setArea] = useState<Area>('people')
|
|
const journalSection = useTripJournalSectionVisible(tripId)
|
|
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
|
|
const current = areas.find(a => a.id === area) ?? areas[0]
|
|
const panelOf = (id: Area) => areas.find(a => a.id === id)
|
|
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
|
|
|
return (
|
|
<>
|
|
<DialogShell
|
|
onClose={onClose}
|
|
labelledBy={labelId}
|
|
width={areas.length > 1 ? 'wide' : 'detail'}
|
|
blocked={!!ask}
|
|
bodyClassName={BODY}
|
|
header={(
|
|
<DialogHeader
|
|
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
|
tint={NEUTRAL_TINT}
|
|
labelId={labelId}
|
|
onClose={onClose}
|
|
eyebrow={t('trip.settings.title')}
|
|
title={tripTitle}
|
|
pills={m.loading ? undefined : (
|
|
<>
|
|
<span className={PILL}><Users size={13} strokeWidth={2.2} />{persons}</span>
|
|
{m.guests.length > 0 && <span className={PILL}><UserRound size={13} strokeWidth={2.2} />{t('members.guests')} {m.guests.length}</span>}
|
|
</>
|
|
)}
|
|
/>
|
|
)}
|
|
>
|
|
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
|
|
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
|
|
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
|
|
{m.canManageMembers && <InviteUser m={m} />}
|
|
<MembersList m={m} onAsk={setAsk} />
|
|
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
|
</AreaPanel>
|
|
{m.canManageShare && (
|
|
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
|
|
<PublicLink tripId={tripId} />
|
|
<InviteLink tripId={tripId} />
|
|
</AreaPanel>
|
|
)}
|
|
{journalSection && (
|
|
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
|
|
<TripJournalLinkSection tripId={tripId} framed={false} />
|
|
</AreaPanel>
|
|
)}
|
|
</div>
|
|
</DialogShell>
|
|
<ConfirmDialog
|
|
isOpen={!!ask}
|
|
onClose={() => setAsk(null)}
|
|
onConfirm={() => { ask?.run(); setAsk(null) }}
|
|
title={ask?.title}
|
|
message={ask?.message}
|
|
confirmLabel={ask?.confirmLabel}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
|
|
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<nav aria-label={t('trip.settings.title')} className={NAV}>
|
|
{areas.map(a => {
|
|
const on = a.id === current
|
|
return (
|
|
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
|
|
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
|
|
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
|
|
<span className="min-w-0">
|
|
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
|
|
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
|
|
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
|
|
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
|
|
{titled && area && (
|
|
<header>
|
|
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
|
|
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
|
|
</header>
|
|
)}
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function InviteUser({ m }: { m: TripMembersState }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<DialogSection label={t('members.inviteUser')}>
|
|
<div className="flex items-center gap-2">
|
|
<CustomSelect
|
|
value={m.selectedUserId}
|
|
onChange={value => m.setSelectedUserId(String(value))}
|
|
placeholder={t('members.selectUser')}
|
|
options={[
|
|
{ value: '', label: t('members.selectUser') },
|
|
// As a string: the select compares values strictly and the pick is kept as one.
|
|
...m.availableUsers.map(u => ({ value: String(u.id), label: u.username })),
|
|
]}
|
|
searchable
|
|
style={{ flex: 1 }}
|
|
size="sm"
|
|
/>
|
|
<DialogButton variant="primary" onClick={() => void m.add()} disabled={m.adding || !m.selectedUserId} icon={<UserPlus size={14} strokeWidth={2.2} />}>
|
|
{m.adding ? '...' : t('members.invite')}
|
|
</DialogButton>
|
|
</div>
|
|
{m.availableUsers.length === 0 && m.allUsers.length > 0 && (
|
|
<p className="m-0 mt-2 text-content-faint" style={fs(11.5)}>{t('members.allHaveAccess')}</p>
|
|
)}
|
|
</DialogSection>
|
|
)
|
|
}
|
|
|
|
/** A round icon button of a row, named by its tooltip; faint until the pointer is on it. */
|
|
function RowAction({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: ReactNode }) {
|
|
return (
|
|
<Tooltip label={label}>
|
|
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
|
|
className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary disabled:opacity-40 ${danger ? 'hover:text-danger' : 'hover:text-content'}`}>
|
|
{children}
|
|
</button>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function SectionLabel({ text, count }: { text: string; count?: number }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-2">
|
|
{text}
|
|
{count != null && <span className={COUNT} style={fs(10)}>{count}</span>}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function MembersList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<DialogSection label={<SectionLabel text={t('members.access')} count={m.loading ? undefined : m.realMembers.length} />}>
|
|
{m.loading ? (
|
|
<div className={ROWS}>
|
|
{[1, 2].map(i => <div key={i} className="h-12 animate-pulse rounded-[10px] bg-surface-tertiary" />)}
|
|
</div>
|
|
) : (
|
|
<div className={ROWS}>
|
|
{m.realMembers.map(member => {
|
|
const isSelf = member.id === m.user?.id
|
|
const isOwner = member.role === 'owner'
|
|
const canRemove = isSelf || (m.canManageMembers && !isOwner)
|
|
return (
|
|
<div key={member.id} className={ROW}>
|
|
<TripMemberAvatar username={member.username} avatarUrl={member.avatar_url ?? null} />
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>{member.username}</span>
|
|
{isSelf && <span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{t('members.you')}</span>}
|
|
{isOwner && (
|
|
<span className={`${BADGE} bg-warning-soft text-warning`} style={fs(10.5)}>
|
|
<Crown size={10} strokeWidth={2.4} />{t('members.owner')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.isCurrentOwner && !isOwner && (
|
|
<RowAction label={t('members.makeOwner')} disabled={m.transferringId === member.id}
|
|
onClick={() => onAsk({
|
|
title: t('members.makeOwner'),
|
|
message: t('members.confirmTransfer', { name: member.username }),
|
|
confirmLabel: t('common.confirm'),
|
|
run: () => void m.transfer(member.id),
|
|
})}>
|
|
<Crown size={15} />
|
|
</RowAction>
|
|
)}
|
|
{canRemove && (
|
|
<RowAction label={isSelf ? t('members.leaveTrip') : t('members.removeAccess')} danger disabled={m.removingId === member.id}
|
|
onClick={() => onAsk({
|
|
title: isSelf ? t('members.leaveTrip') : t('members.removeAccess'),
|
|
message: isSelf ? t('members.confirmLeave') : t('members.confirmRemove'),
|
|
confirmLabel: t('common.confirm'),
|
|
run: () => void m.remove(member.id, isSelf),
|
|
})}>
|
|
{isSelf ? <LogOut size={15} /> : <UserMinus size={15} />}
|
|
</RowAction>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</DialogSection>
|
|
)
|
|
}
|
|
|
|
/** Accountless participants (#1362): the owner names, renames and removes them. */
|
|
function GuestsList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => void }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<DialogSection label={<SectionLabel text={t('members.guests')} count={m.guests.length || undefined} />}>
|
|
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('members.guestsHint')}</p>
|
|
{m.guests.length > 0 && (
|
|
<div className={`${ROWS} mb-2.5`}>
|
|
{m.guests.map(g => (
|
|
<div key={g.id} className={ROW}>
|
|
<TripMemberAvatar username={g.username} avatarUrl={null} />
|
|
{m.renamingGuestId === g.id ? (
|
|
<input
|
|
autoFocus
|
|
value={m.renameValue}
|
|
onChange={e => m.setRenameValue(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') void m.commitRename(g.id)
|
|
// Escape ends the rename, not the dialog.
|
|
if (e.key === 'Escape') { e.preventDefault(); m.cancelRename() }
|
|
}}
|
|
onBlur={() => void m.commitRename(g.id)}
|
|
maxLength={50}
|
|
aria-label={t('common.rename')}
|
|
className={`${INPUT} flex-1`}
|
|
/>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>{g.username}</span>
|
|
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>
|
|
<UserRound size={10} strokeWidth={2.4} />{t('members.guest')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{m.isCurrentOwner && m.renamingGuestId !== g.id && (
|
|
<>
|
|
<RowAction label={t('common.rename')} onClick={() => m.startRename(g)}>
|
|
<Pencil size={14} />
|
|
</RowAction>
|
|
<RowAction label={t('members.removeAccess')} danger disabled={m.removingId === g.id}
|
|
onClick={() => onAsk({
|
|
title: t('members.removeAccess'),
|
|
message: t('members.confirmRemoveGuest'),
|
|
confirmLabel: t('common.delete'),
|
|
run: () => void m.removeGuest(g.id),
|
|
})}>
|
|
<Trash2 size={14} />
|
|
</RowAction>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{m.isCurrentOwner && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
value={m.newGuestName}
|
|
onChange={e => m.setNewGuestName(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') void m.addGuest() }}
|
|
placeholder={t('members.guestNamePlaceholder')}
|
|
aria-label={t('members.guestNamePlaceholder')}
|
|
maxLength={50}
|
|
className={`${INPUT} flex-1`}
|
|
/>
|
|
<DialogButton onClick={() => void m.addGuest()} disabled={m.addingGuest || !m.newGuestName.trim()} icon={<Plus size={14} strokeWidth={2.2} />}>
|
|
{m.addingGuest ? '...' : t('members.addGuest')}
|
|
</DialogButton>
|
|
</div>
|
|
)}
|
|
</DialogSection>
|
|
)
|
|
}
|
|
|
|
/** A link as a read-only line with its copy button, the way the calendar feed shows its addresses. */
|
|
function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCopy: () => void }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input py-1 ps-3 pe-1">
|
|
<input type="text" value={url} readOnly aria-label={url} onFocus={e => e.currentTarget.select()}
|
|
className="min-w-0 flex-1 truncate border-0 bg-transparent font-mono text-content outline-none" style={fs(11.5)} />
|
|
<button type="button" onClick={onCopy}
|
|
className={`inline-flex flex-none items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 font-semibold ${copied ? 'bg-success-soft text-success' : 'bg-accent text-accent-text hover:opacity-90'}`}
|
|
style={fs(11.5, 'body')}>
|
|
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2.2} />}
|
|
{copied ? t('common.copied') : t('common.copy')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
|
return (
|
|
<section className={PANEL}>
|
|
<div className="flex items-start gap-2.5">
|
|
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm">{icon}</span>
|
|
<div className="min-w-0">
|
|
<h3 className="m-0 font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
|
|
<p className="m-0 mt-0.5 leading-normal text-content-faint" style={fs(11.5)}>{hint}</p>
|
|
</div>
|
|
</div>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/** One toggle of the link, a filled pill with a tick while it is on. */
|
|
function PermChip({ on, disabled, label, hint, onToggle }: { on: boolean; disabled?: boolean; label: string; hint?: string; onToggle: () => void }) {
|
|
const chip = (
|
|
<button type="button" aria-pressed={on} disabled={disabled} onClick={onToggle}
|
|
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-semibold disabled:cursor-default ${on ? 'bg-accent text-accent-text disabled:opacity-70' : 'bg-surface-card text-content-muted ring-1 ring-edge-faint hover:text-content'}`}
|
|
style={fs(11.5, 'body')}>
|
|
{on && <Check size={11} strokeWidth={2.6} />}{label}
|
|
</button>
|
|
)
|
|
return hint ? <Tooltip label={hint}>{chip}</Tooltip> : chip
|
|
}
|
|
|
|
/** The read-only public link, and which parts of the trip it shows. */
|
|
function PublicLink({ tripId }: { tripId: number }) {
|
|
const { t } = useTranslation()
|
|
const link = useShareLink(tripId)
|
|
if (link.loading) return null
|
|
return (
|
|
<LinkCard icon={<Link2 size={15} strokeWidth={2.2} />} title={t('share.linkTitle')} hint={t('share.linkHint')}>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{SHARE_SECTIONS.map(opt => (
|
|
<PermChip key={opt.key} on={link.perms[opt.key]} disabled={opt.always} label={t(opt.label)}
|
|
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
|
|
))}
|
|
</div>
|
|
<div>
|
|
<span className={LABEL}>{t('share.options')}</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{SHARE_OPTIONS.map(opt => (
|
|
<PermChip key={opt.key} on={link.perms[opt.key]} label={t(opt.label)} hint={t(`${opt.label}Hint`)}
|
|
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
{link.url ? (
|
|
<>
|
|
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
|
|
<div>
|
|
<button type="button" onClick={() => void link.remove()} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
|
|
<Trash2 size={13} strokeWidth={2.2} />{t('share.deleteLink')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<AddRowButton onClick={() => void link.create()}>{t('share.createLink')}</AddRowButton>
|
|
)}
|
|
</LinkCard>
|
|
)
|
|
}
|
|
|
|
/** The link an existing user opens to join as a member (#1143). */
|
|
function InviteLink({ tripId }: { tripId: number }) {
|
|
const { t } = useTranslation()
|
|
const link = useTripInviteLink(tripId)
|
|
if (link.loading) return null
|
|
return (
|
|
<LinkCard icon={<UserPlus size={15} strokeWidth={2.2} />} title={t('trip.invite.linkTitle')} hint={t('trip.invite.linkHint')}>
|
|
{link.url ? (
|
|
<>
|
|
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
|
|
<div className="flex flex-wrap gap-2">
|
|
<DialogButton onClick={() => void link.create()} disabled={link.busy} icon={<Link2 size={14} strokeWidth={2.2} />}>
|
|
{t('trip.invite.regenerate')}
|
|
</DialogButton>
|
|
<button type="button" onClick={() => void link.remove()} disabled={link.busy} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
|
|
<Trash2 size={13} strokeWidth={2.2} />{t('trip.invite.disable')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<AddRowButton onClick={() => void link.create()} disabled={link.busy}>{t('trip.invite.create')}</AddRowButton>
|
|
)}
|
|
</LinkCard>
|
|
)
|
|
}
|