mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
7e3b9fd039
The desktop layout rendered notes and links as a pair and dropped whichever of the two was on its own, so turning the new Links feature off on an otherwise default install made the Notes panel disappear. Both rows are now split by kind and rendered only when they hold something, and one function decides what a panel id renders, because the branch that lost a panel did so by not listing it. The message and its attachment rows are written in one transaction. The caller has already committed the image bytes by then, so a message that landed without them left those bytes with nothing pointing at them and nothing to sweep them. Deleting one now drops the blobs after the rows are gone rather than before. A body the Zod pipe rejects used to leave up to four spooled images on disk: pipes run after interceptors, so multer had already written them and the handler's own cleanup was never reached. An interceptor removes them on any failure before the handler, which also covers every other schema mismatch, and the length check that could never fire is gone. Adding, pinning and deleting a link now says something when it fails instead of raising an unhandled rejection, the list has real types, and the rejected-image toast is translated in all 23 locales rather than shipping English everywhere. The picking, previewing and clearing of chat images moved into one hook both shells use: it was copied into two, and both rebuilt every preview on each change and leaked an object URL per image per pick. Tests: the four link routes end to end with the validation pipe the harness was missing, the image filter against the disguised-extension case, and the layout combinations that drop a panel.
187 lines
7.5 KiB
TypeScript
187 lines
7.5 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'
|
|
|
|
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-card 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} />}
|
|
</>
|
|
)
|
|
const panelRow = (ids: string[]) => (
|
|
<div style={{ flex: 1, display: 'flex', gap: 12, 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 style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
|
<div className={cardClass} style={{ flex: 1 }}>
|
|
<CollabChat tripId={tripId} currentUser={user} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (chatOn) {
|
|
// Chat left (380px) + right panels
|
|
return (
|
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
|
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
|
<CollabChat tripId={tripId} currentUser={user} />
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, 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 style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
|
{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>
|
|
)
|
|
}
|