Files
TREK/client/src/components/Planner/IcsSubscribeModal.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
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.
2026-09-27 21:00:19 +02:00

280 lines
10 KiB
TypeScript

import { useState, useEffect, useCallback, useId, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { X, RefreshCw, Calendar, Power } from 'lucide-react'
import { SubscribeLinks } from './SubscribeLinks'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
interface IcsSubscribeModalProps {
/** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */
endpoint: string
title: string
description: string
onClose: () => void
}
// A server that has no APP_URL configured hands back a host-relative path; the
// webcal:// handoff and Google deep link need an absolute URL, so resolve it
// against the current origin as a fallback.
function absolutize(url: string): string {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
if (url.startsWith('/')) return window.location.origin + url
return url
}
/**
* Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it
* only *reads* the current token — it never mints one silently. The user
* explicitly enables the public link, and can rotate or fully turn it off.
*
* The phone keeps its own markup; the desktop draws the same steps in the
* planner's dialog frame, with the link's controls in the bar at the foot.
*/
export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) {
const tokenUrl = `${endpoint}/token`
const [feedUrl, setFeedUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const isPhone = useIsPhone()
const titleId = useId()
const httpsUrl = feedUrl ? absolutize(feedUrl) : ''
const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : ''
const load = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(tokenUrl, { credentials: 'include' })
if (res.ok) {
const data = await res.json() as { feed_url: string | null }
setFeedUrl(data.feed_url)
}
} catch { /* ignore */ }
setLoading(false)
}, [tokenUrl])
useEffect(() => { load() }, [load])
const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => {
setBusy(true)
try {
const res = await fetch(tokenUrl, { method, credentials: 'include' })
if (res.ok) {
const data = await res.json() as { feed_url: string | null }
setFeedUrl(data.feed_url)
}
} catch { /* ignore */ }
setBusy(false)
}
if (!isPhone) {
return (
<DesktopSubscribeDialog
titleId={titleId}
title={title}
description={description}
onClose={onClose}
loading={loading}
busy={busy}
feedUrl={feedUrl}
httpsUrl={httpsUrl}
webcalUrl={webcalUrl}
mutate={mutate}
/>
)
}
return createPortal(
<div
role="presentation"
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '16px',
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--bg-card, white)',
borderRadius: 14,
padding: '22px 24px',
width: '100%',
maxWidth: 420,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
border: '1px solid var(--border-faint)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
position: 'relative',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
</div>
<button type="button"
onClick={onClose}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
color: 'var(--text-muted)', borderRadius: 6, display: 'flex',
}}
>
<X size={15} strokeWidth={2} />
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
{description}
</p>
{loading ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Loading…
</div>
) : !feedUrl ? (
<>
<button type="button"
onClick={() => mutate('POST')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', padding: '9px 14px', borderRadius: 9, border: 'none',
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
}}
>
<Calendar size={14} strokeWidth={2} />
Enable calendar subscription
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
</p>
</>
) : (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
<button type="button"
onClick={() => mutate('PUT')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--text-muted)',
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate
</button>
<button type="button"
onClick={() => mutate('DELETE')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--danger, #dc2626)',
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<Power size={11} strokeWidth={2} />
Turn off
</button>
</div>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
</p>
</>
)}
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>,
document.body
)
}
// A secondary footer button in the danger colour, for the switch that takes the link away.
const TURN_OFF_BTN = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-danger hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
function DesktopSubscribeDialog({ titleId, title, description, onClose, loading, busy, feedUrl, httpsUrl, webcalUrl, mutate }: {
titleId: string
title: string
description: string
onClose: () => void
loading: boolean
busy: boolean
feedUrl: string | null
httpsUrl: string
webcalUrl: string
mutate: (method: 'POST' | 'PUT' | 'DELETE') => Promise<void>
}) {
const header = (
<DialogHeader
tile={<DialogTile><Calendar size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={titleId}
onClose={onClose}
title={title}
sub={description}
subWraps
/>
)
const hint = 'm-0 leading-normal text-content-muted'
let body: ReactNode
let footer: ReactNode = null
if (loading) {
body = <div className="py-4 text-center text-content-muted" style={fs(12.5, 'body')}>Loading…</div>
} else if (!feedUrl) {
body = (
<p className={hint} style={fs(12.5, 'body')}>
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
</p>
)
footer = (
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={() => mutate('POST')} disabled={busy} icon={<Calendar size={14} strokeWidth={2} />}>
Enable calendar subscription
</DialogButton>
</DialogFooter>
)
} else {
body = (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<p className={hint} style={fs(11.5)}>
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
</p>
</>
)
footer = (
<DialogFooter>
<DialogButton onClick={() => mutate('PUT')} disabled={busy} icon={<RefreshCw size={14} strokeWidth={2} className={busy ? 'animate-spin' : ''} />}>
Regenerate
</DialogButton>
<FooterSpacer />
<button type="button" onClick={() => mutate('DELETE')} disabled={busy} className={TURN_OFF_BTN} style={fs(13, 'body')}>
<Power size={14} strokeWidth={2} />
Turn off
</button>
</DialogFooter>
)
}
return (
<DialogShell onClose={onClose} labelledBy={titleId} width="narrow" header={header} footer={footer}>
{body}
</DialogShell>
)
}