mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
0dd07befa7
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
|
import CollabChat from './CollabChat'
|
|
import CollabNotes from './CollabNotes'
|
|
import CollabPolls from './CollabPolls'
|
|
import WhatsNextWidget from './WhatsNextWidget'
|
|
import CollabLinks from './CollabLinks'
|
|
import CollabPanelHead from './CollabPanelHead'
|
|
|
|
function useIsDesktop(breakpoint = 1024) {
|
|
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
|
useEffect(() => {
|
|
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
|
|
window.addEventListener('resize', check)
|
|
return () => window.removeEventListener('resize', check)
|
|
}, [breakpoint])
|
|
return isDesktop
|
|
}
|
|
|
|
const cardClass = 'flex flex-col bg-surface-secondary rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
|
|
|
interface TripMember {
|
|
id: number
|
|
username: string
|
|
avatar_url?: string | null
|
|
}
|
|
|
|
interface CollabFeatures {
|
|
chat: boolean
|
|
notes: boolean
|
|
links?: boolean
|
|
polls: boolean
|
|
whatsnext: boolean
|
|
}
|
|
|
|
interface CollabPanelProps {
|
|
tripId: number
|
|
tripMembers?: TripMember[]
|
|
collabFeatures?: CollabFeatures
|
|
}
|
|
|
|
const ALL_TABS = [
|
|
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
|
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
|
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
|
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
|
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
|
]
|
|
|
|
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
|
const { user } = useAuthStore()
|
|
const { t } = useTranslation()
|
|
const isDesktop = useIsDesktop()
|
|
|
|
// Older server/admin configs predate the Links feature; merge defaults so a
|
|
// missing `links` key does not silently hide the new panel.
|
|
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
|
|
|
const tabs = useMemo(() =>
|
|
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
|
...tab,
|
|
label: t(tab.labelKey) || tab.fallback,
|
|
})),
|
|
[features, t])
|
|
|
|
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
|
|
|
// If active tab gets disabled, switch to first available
|
|
useEffect(() => {
|
|
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
|
setMobileTab(tabs[0].id)
|
|
}
|
|
}, [tabs, mobileTab])
|
|
|
|
const chatOn = features.chat
|
|
const rightPanels = [
|
|
features.notes && 'notes', features.links && 'links',
|
|
features.polls && 'polls',
|
|
features.whatsnext && 'whatsnext',
|
|
].filter(Boolean) as string[]
|
|
|
|
// One place decides what a panel id renders. It was spelled out at five call
|
|
// sites, and the layout branch below dropped a panel precisely because one of
|
|
// those copies did not list it.
|
|
const renderPanel = (p: string) => (
|
|
<>
|
|
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
|
{p === 'links' && <CollabLinks tripId={tripId} />}
|
|
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
|
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
|
</>
|
|
)
|
|
// The chat carries the same head band as the panels beside it.
|
|
const chatPanel = (
|
|
<>
|
|
<CollabPanelHead icon={MessageCircle} title={t('collab.tabs.chat') || 'Chat'} />
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<CollabChat tripId={tripId} currentUser={user} />
|
|
</div>
|
|
</>
|
|
)
|
|
const panelRow = (ids: string[]) => (
|
|
<div style={{ flex: 1, display: 'flex', gap: 16, minHeight: 0 }}>
|
|
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
|
</div>
|
|
)
|
|
|
|
if (tabs.length === 0) return null
|
|
|
|
if (isDesktop) {
|
|
// Chat always 380px fixed when on. Right panels share remaining space.
|
|
// If chat off, all panels share full width equally.
|
|
if (chatOn && rightPanels.length === 0) {
|
|
// Only chat
|
|
return (
|
|
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
|
<div className={cardClass} style={{ flex: 1 }}>
|
|
{chatPanel}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (chatOn) {
|
|
// Chat left (380px) + right panels
|
|
return (
|
|
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
|
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
|
{chatPanel}
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'hidden', minHeight: 0 }}>
|
|
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
|
{rightPanels.length >= 3 && (() => {
|
|
// Two rows, split by kind rather than by a pair being present:
|
|
// the old condition needed notes AND links together, so turning
|
|
// the new Links feature off dropped Notes out of the layout
|
|
// entirely. Each row renders only when it has something in it.
|
|
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
|
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
|
return <>
|
|
{written.length > 0 && panelRow(written)}
|
|
{rest.length > 0 && panelRow(rest)}
|
|
</>
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Chat off — remaining panels share full width
|
|
const panels = rightPanels
|
|
return (
|
|
<div className="flex h-full min-h-0 gap-4 overflow-hidden px-7 py-6">
|
|
{panels.map(p => (
|
|
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Mobile: tab bar + single panel (only enabled tabs)
|
|
return (
|
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
|
<div style={{
|
|
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
|
|
background: 'var(--bg-card)', flexShrink: 0,
|
|
}}>
|
|
{tabs.map(tab => {
|
|
const active = mobileTab === tab.id
|
|
return (
|
|
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
|
background: active ? 'var(--accent)' : 'transparent',
|
|
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
|
transition: 'all 0.15s',
|
|
}}>
|
|
{tab.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
|
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
|
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
|
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
|
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
|
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|