mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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).
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Bookmark, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar widget — a glassy `.tool` card (cloned from the dashboard's
|
||||
* UpcomingTool) that surfaces the user's saved-places library: a total count
|
||||
* plus the most recently saved places. Every row and the header "→" jump to
|
||||
* /collections.
|
||||
*
|
||||
* Self-contained on purpose: it fetches its own data on mount, and it is only
|
||||
* ever mounted when BOTH gates are satisfied (admin addon enabled AND the
|
||||
* per-user widget flag on — see DashboardPage `showCollections`). That keeps the
|
||||
* data fetch strictly "only when shown" and means the dashboard never hits
|
||||
* /api/addons/collections while the addon is disabled.
|
||||
*/
|
||||
export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [places, setPlaces] = useState<CollectionPlace[]>([])
|
||||
const [count, setCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await collectionsApi.list()
|
||||
if (cancelled) return
|
||||
// Union every list the user owns or co-owns, deduped, newest first —
|
||||
// mirrors the store's "All saved" pseudo-list so the widget shows the
|
||||
// same recent saves the page does.
|
||||
const results = await Promise.all(data.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
|
||||
if (cancelled) return
|
||||
const seen = new Set<number>()
|
||||
const merged: CollectionPlace[] = []
|
||||
for (const res of results) {
|
||||
if (!res) continue
|
||||
for (const p of res.places) {
|
||||
if (seen.has(p.id)) continue
|
||||
seen.add(p.id)
|
||||
merged.push(p)
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
||||
setCount(merged.length)
|
||||
setPlaces(merged.slice(0, 4))
|
||||
} catch {
|
||||
if (!cancelled) { setPlaces([]); setCount(0) }
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
<div className="tool-title"><Bookmark size={14} /> {t('collections.widget.title')}</div>
|
||||
<button className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? null : places.length === 0 ? (
|
||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-count">{t('collections.widget.savedCount', { count })}</div>
|
||||
<div className="col-list">
|
||||
{places.map(p => (
|
||||
<button key={p.id} className="col-item" onClick={onOpen}>
|
||||
<PlaceAvatar
|
||||
place={p}
|
||||
size={36}
|
||||
category={p.category ? { color: p.category.color ?? undefined, icon: p.category.icon ?? undefined } : null}
|
||||
/>
|
||||
<div className="col-info">
|
||||
<div className="t">{p.name}</div>
|
||||
<div className="s">{p.category?.name || p.address || ''}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type MobileWidgetKey = keyof AppearanceConfig['dashboard']['mobile']
|
||||
const WIDGET_LABELS: Record<string, string> = {
|
||||
sidebar: 'Right sidebar',
|
||||
currency: 'Currency',
|
||||
collections: 'Collections',
|
||||
timezones: 'Timezones',
|
||||
upcomingReservations: 'Upcoming reservations',
|
||||
atlas: 'Atlas / countries',
|
||||
@@ -53,11 +54,11 @@ const WIDGET_LABELS: Record<string, string> = {
|
||||
// widgets only matter while the sidebar is shown.
|
||||
const DESKTOP_GROUPS: { id: string; fallback: string; master?: DesktopWidgetKey; keys: DesktopWidgetKey[] }[] = [
|
||||
{ id: 'belowHero', fallback: 'Below the hero', keys: ['atlas', 'tripsTotal', 'daysTraveled', 'distanceFlown'] },
|
||||
{ id: 'rightSidebar', fallback: 'Right sidebar', master: 'sidebar', keys: ['currency', 'timezones', 'upcomingReservations'] },
|
||||
{ id: 'rightSidebar', fallback: 'Right sidebar', master: 'sidebar', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] },
|
||||
]
|
||||
const MOBILE_GROUPS: { id: string; fallback: string; keys: MobileWidgetKey[] }[] = [
|
||||
{ id: 'belowHero', fallback: 'Below the hero', keys: ['tripsTotal', 'daysTraveled'] },
|
||||
{ id: 'bottomOfPage', fallback: 'Bottom of page', keys: ['currency', 'timezones', 'upcomingReservations'] },
|
||||
{ id: 'bottomOfPage', fallback: 'Bottom of page', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] },
|
||||
]
|
||||
|
||||
// shared segmented-button style (matches DisplaySettingsTab)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { LayoutGrid, List as ListIcon, Map as MapIcon, Search, Bookmark, Plus, CheckSquare, X, Trash2, Copy } from 'lucide-react'
|
||||
import { LayoutGrid, List as ListIcon, Map as MapIcon, Search, Bookmark, Plus, CheckSquare, X, Trash2, Copy, Share2, Users } from 'lucide-react'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import ListsRail from '../components/Collections/ListsRail'
|
||||
@@ -7,6 +7,7 @@ import CollectionGrid from '../components/Collections/CollectionGrid'
|
||||
import CollectionList from '../components/Collections/CollectionList'
|
||||
import CollectionMap from '../components/Collections/CollectionMap'
|
||||
import CopyToTripModal from '../components/Collections/CopyToTripModal'
|
||||
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
|
||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
import type { TranslationFn } from '../types'
|
||||
import type { CollectionView, StatusFilter } from '../store/collectionStore'
|
||||
@@ -199,6 +200,21 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewSwitch view={c.view} onChange={c.setView} t={t} />
|
||||
{c.canShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => c.setShowShare(true)}
|
||||
className="relative inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium border bg-surface-card text-content-secondary border-edge hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{c.isOwner ? <Share2 size={14} /> : <Users size={14} />}
|
||||
<span className="hidden sm:inline">{c.isOwner ? t('collections.share.button') : t('collections.shared')}</span>
|
||||
{c.shareMemberCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-text text-[10px] font-semibold tabular-nums">
|
||||
{c.shareMemberCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{showSelect && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -314,6 +330,20 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Share / fusion */}
|
||||
{c.canShare && typeof c.activeId === 'number' && c.activeCollection && (
|
||||
<ShareCollectionModal
|
||||
isOpen={c.showShare}
|
||||
onClose={() => c.setShowShare(false)}
|
||||
collectionId={c.activeId}
|
||||
collectionName={c.activeCollection.name}
|
||||
isOwner={c.isOwner}
|
||||
members={c.members}
|
||||
onAfterLeave={c.handleAfterLeave}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New list modal */}
|
||||
<Modal
|
||||
isOpen={c.showNewList}
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
LayoutGrid, List, Ticket, X, CalendarPlus,
|
||||
} from 'lucide-react'
|
||||
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
|
||||
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { normalizeAppearance } from '@trek/shared'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
@@ -119,8 +121,11 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const showCurrency = sideWidgets.currency
|
||||
const showTimezones = sideWidgets.timezones
|
||||
const showUpcoming = sideWidgets.upcomingReservations
|
||||
// Collections is double-gated: the admin addon AND the per-user widget flag.
|
||||
const isAddonEnabled = useAddonStore(s => s.isEnabled)
|
||||
const showCollections = isAddonEnabled('collections') && sideWidgets.collections
|
||||
// Desktop has a master toggle for the whole right column; off → centered layout.
|
||||
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showTimezones || showUpcoming)
|
||||
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -226,6 +231,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{sidebarVisible && (
|
||||
<aside className="page-sidebar">
|
||||
{showCurrency && <CurrencyTool />}
|
||||
{showCollections && <CollectionsWidget onOpen={() => navigate('/collections')} />}
|
||||
{showTimezones && <TimezoneTool locale={locale} />}
|
||||
{showUpcoming && <UpcomingTool items={upcoming} locale={locale} onOpen={(tripId) => navigate(`/trips/${tripId}`)} />}
|
||||
</aside>
|
||||
|
||||
@@ -48,6 +48,7 @@ export function useCollections() {
|
||||
const [confirmDeleteList, setConfirmDeleteList] = useState<number | null>(null)
|
||||
const [mobileRailOpen, setMobileRailOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
// The place ids the Copy-to-trip modal is open for (null = closed). Single
|
||||
// place from the detail panel, or the select-mode set for a bulk copy.
|
||||
const [copyIds, setCopyIds] = useState<number[] | null>(null)
|
||||
@@ -97,6 +98,14 @@ export function useCollections() {
|
||||
const isAllSaved = activeId === ALL_SAVED
|
||||
const isOwner = activeCollection?.is_owner ?? false
|
||||
|
||||
// Sharing is offered on any real (non "All saved") list the user can see. The
|
||||
// badge counts everyone but the owner — accepted collaborators + pending invites.
|
||||
const canShare = typeof activeId === 'number' && activeCollection != null
|
||||
const shareMemberCount = useMemo(() => members.filter(m => !m.is_owner).length, [members])
|
||||
|
||||
// Close the share modal if the active list changes out from under it.
|
||||
useEffect(() => { setShowShare(false) }, [activeId])
|
||||
|
||||
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
|
||||
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
|
||||
|
||||
@@ -210,6 +219,13 @@ export function useCollections() {
|
||||
}
|
||||
}, [declineInvite, toast, t])
|
||||
|
||||
// Member left the active shared list — the store already cleared it; bounce to
|
||||
// the rail and close the share modal. Errors are surfaced inside the modal.
|
||||
const handleAfterLeave = useCallback(() => {
|
||||
setShowShare(false)
|
||||
navigate('/collections')
|
||||
}, [navigate])
|
||||
|
||||
// ── Detail panel (re-pointed PlaceInspector in collection mode) ──────
|
||||
const selectedPlace = useMemo(
|
||||
() => places.find(p => p.id === selectedPlaceId) ?? null,
|
||||
@@ -263,6 +279,7 @@ export function useCollections() {
|
||||
t, language, dark, navigate,
|
||||
// store data
|
||||
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, members, incomingInvites, counts,
|
||||
view, statusFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
loading, placesLoading,
|
||||
@@ -273,6 +290,7 @@ export function useCollections() {
|
||||
editingListId, editingName, setEditingName,
|
||||
confirmDeleteList, setConfirmDeleteList,
|
||||
mobileRailOpen, setMobileRailOpen,
|
||||
showShare, setShowShare, handleAfterLeave,
|
||||
// detail panel + copy-to-trip
|
||||
selectedPlace, detailPlace, detailCategories, handleCloseDetail,
|
||||
handleDetailStatus, handleDetailRemove,
|
||||
|
||||
@@ -541,6 +541,16 @@
|
||||
.trek-dash .upc-type.other { background: var(--bg-2); color: var(--ink-2); }
|
||||
.trek-dash .upc-type svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ----------------- collections widget ----------------- */
|
||||
.trek-dash .col-count { font-size: 12px; color: var(--ink-3); font-weight: 500; margin-bottom: 12px; }
|
||||
.trek-dash .col-empty { font-size: 13px; color: var(--ink-3); }
|
||||
.trek-dash .col-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.trek-dash .col-item { display: grid; grid-template-columns: 36px 1fr; gap: 12px; padding: 8px; border-radius: 12px; align-items: center; transition: background .12s; cursor: pointer; width: 100%; text-align: left; }
|
||||
.trek-dash .col-item:hover { background: var(--surface-2); }
|
||||
.trek-dash .col-info { min-width: 0; }
|
||||
.trek-dash .col-info .t { font-size: 14px; font-weight: 500; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trek-dash .col-info .s { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ----------------- responsive ----------------- */
|
||||
@media (max-width: 1280px) {
|
||||
.trek-dash .page { padding-left: 32px; padding-right: 32px; grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user