mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
This commit is contained in:
@@ -451,6 +451,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
|
||||
@@ -214,7 +214,12 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
@@ -235,7 +240,12 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
|
||||
@@ -799,7 +799,7 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
|
||||
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{menu && <div role="presentation" className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -972,8 +972,8 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
|
||||
<button type="button" onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
@@ -994,8 +994,8 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
@@ -1508,8 +1508,8 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<Screenshot url={item.screenshotUrl} className="w-full aspect-[16/9]" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
@@ -1610,7 +1610,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.detailsTitle')}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
|
||||
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
|
||||
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{sizeKb !== null && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
|
||||
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
@@ -1760,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
: 'admin.plugins.sig.invalidBody'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1824,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1884,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
|
||||
]
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
|
||||
@@ -318,9 +318,9 @@ export default function BackupPanel() {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
@@ -458,11 +458,15 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
created_at: String(new Date()),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
|
||||
@@ -18,7 +18,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
|
||||
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
|
||||
function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
const row = screen.getByText(name).closest(selector) as HTMLElement;
|
||||
return within(row).getAllByRole('button');
|
||||
@@ -588,11 +588,11 @@ describe('PackingTemplateManager', () => {
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await screen.findByText('Template deleted');
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
@@ -611,14 +611,14 @@ describe('PackingTemplateManager', () => {
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[1]);
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.clear(screen.getByDisplayValue('Beach Trip'));
|
||||
await user.type(screen.getByRole('textbox'), '{Enter}');
|
||||
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// Blurring the field commits the pending name — here the request fails
|
||||
await user.click(templateButtons('Beach Trip')[1]);
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Winter');
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function hexLighten(hex: string, amount: number): string {
|
||||
}
|
||||
|
||||
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
if (v == null || Number.isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
@@ -586,7 +586,10 @@ describe('BudgetCategoryTable — member chips', () => {
|
||||
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
|
||||
const { setBudgetItemMembers, container } = withMembers()
|
||||
// The mobile stack under the name and the persons column both carry an editor.
|
||||
const editors = container.querySelectorAll('td button')
|
||||
// Assigned chips are buttons of their own, so the editors are the two that
|
||||
// carry the picker icon.
|
||||
const editors = Array.from(container.querySelectorAll('td button'))
|
||||
.filter(b => b.querySelector('.lucide-pencil, .lucide-users'))
|
||||
|
||||
fireEvent.click(editors[0])
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v: string | number | null = editValue
|
||||
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = Number.isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
@@ -45,14 +45,27 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
// Editable cells are real buttons so they can be tabbed to and pressed. A
|
||||
// read-only cell renders no control at all rather than a disabled one: it is
|
||||
// plain text, and a disabled button would be announced as an unavailable
|
||||
// control that never becomes available. Same split the member chips use.
|
||||
const cellStyle: React.CSSProperties = {
|
||||
padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
width: '100%', textAlign: 'left', ...style,
|
||||
}
|
||||
const content = display || placeholder || '-'
|
||||
|
||||
if (readOnly) return <div style={{ ...cellStyle, cursor: 'default' }}>{content}</div>
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ ...cellStyle, cursor: 'pointer' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
|
||||
return { onSetMembers, onTogglePaid, ...utils }
|
||||
}
|
||||
|
||||
/**
|
||||
* The member picker trigger. A chip that can be clicked is a real button too,
|
||||
* so it is the icon that tells the trigger apart from the chips before it.
|
||||
*/
|
||||
function picker(): HTMLElement {
|
||||
const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users'))
|
||||
if (!trigger) throw new Error('no picker trigger rendered')
|
||||
return trigger
|
||||
}
|
||||
|
||||
describe('ChipWithTooltip', () => {
|
||||
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
|
||||
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
|
||||
@@ -76,7 +86,7 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(picker()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
|
||||
@@ -114,9 +124,9 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
const rows = screen.getAllByRole('button').slice(1)
|
||||
const rows = screen.getAllByRole('button').slice(2)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('ada')
|
||||
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
|
||||
@@ -127,52 +137,52 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
fireEvent.click(screen.getAllByRole('button')[3])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-013: picking an assigned member removes them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
const trigger = picker()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
fireEvent.mouseDown(screen.getAllByRole('button')[1])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
fireEvent.mouseDown(screen.getAllByRole('button')[2])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.mouseDown(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
const trigger = picker()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
|
||||
setup({ compact: false })
|
||||
|
||||
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
|
||||
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
|
||||
expect(picker().style.width).toBe('28px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ChipWithTooltipProps {
|
||||
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLElement | null>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
@@ -35,22 +35,34 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
// Identical for both shells — a clickable chip is a real button so it can be
|
||||
// tabbed to and pressed, a decorative one stays a plain div.
|
||||
const chipStyle = {
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
padding: 0, fontFamily: 'inherit',
|
||||
}
|
||||
const inner = avatarUrl
|
||||
? <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{onClick ? (
|
||||
<button type="button" ref={el => { ref.current = el }} aria-label={label}
|
||||
onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick} style={chipStyle}>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div ref={el => { ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
style={chipStyle}>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
{hover && createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
@@ -163,7 +175,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={tm.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
{avatarUrl ? <img src={avatarUrl} alt={username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -83,7 +83,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
{/* Decoration inside the settlement toggle: the hint hangs off
|
||||
hover, and the click only keeps the toggle from firing. A
|
||||
role of its own would give the toggle a second name and a
|
||||
nested control, so it declares itself presentational. */}
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
role="presentation"
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
||||
@@ -706,7 +706,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return (
|
||||
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<button type="button" title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
<button type="button" title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -858,7 +858,7 @@ describe('CollabNotes', () => {
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
// Find the "Transport" category name span and click to edit
|
||||
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
|
||||
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
if (categoryNameSpan) {
|
||||
await user.click(categoryNameSpan);
|
||||
// Now an input with value "Transport" should appear
|
||||
@@ -980,7 +980,7 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
|
||||
// Find and click the "OldCat" category name span to enter edit mode
|
||||
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
|
||||
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
if (oldCatSpan) {
|
||||
await user.click(oldCatSpan);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
@@ -1767,10 +1767,13 @@ describe('CollabNotes details', () => {
|
||||
if (!img) throw new Error('image attachment not rendered yet');
|
||||
return img;
|
||||
});
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(image.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(image.style.transform).toBe('scale(1)');
|
||||
// The clickable thumbnail is a real button wrapped around the image, so the
|
||||
// hover transform lands on that button.
|
||||
const imageTile = image.closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1)');
|
||||
fireEvent.click(image);
|
||||
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -1841,7 +1844,7 @@ describe('CollabNotes details', () => {
|
||||
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
@@ -1894,7 +1897,7 @@ describe('CollabNotes details', () => {
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
|
||||
@@ -403,7 +403,8 @@ function ViewNoteModal(S: NotesState) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10000, padding: 16,
|
||||
}}
|
||||
onClick={() => setViewingNote(null)}
|
||||
role="presentation"
|
||||
onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -412,7 +413,6 @@ function ViewNoteModal(S: NotesState) {
|
||||
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
|
||||
@@ -462,17 +462,18 @@ function ViewNoteModal(S: NotesState) {
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
<button type="button" title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLElement>; onMouseLeave?: React.MouseEventHandler<HTMLElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
if (!authSrc) return null
|
||||
// A clickable thumbnail gets a real button around the image so it is
|
||||
// reachable by keyboard. The caller's style stays on the outer box, so the
|
||||
// hover transform/transition still animates the element that carries them.
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
|
||||
style={{ ...style, padding: 0, border: 'none', background: 'none', display: 'block' }}>
|
||||
<img src={authSrc} alt={alt} style={{ ...style, width: '100%', height: '100%', display: 'block' }} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <img src={authSrc} alt={alt} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />
|
||||
}
|
||||
|
||||
@@ -171,17 +171,18 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
<button type="button" key={a.id} title={a.original_name} aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
|
||||
@@ -69,11 +69,11 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||
}} onClick={onClose}>
|
||||
}} role="presentation" onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
|
||||
@@ -25,10 +25,10 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0', background: 'none', border: 'none', textAlign: 'left', fontFamily: 'inherit' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} role="presentation" onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||
@@ -46,7 +46,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
</div>
|
||||
) : (
|
||||
/* Document viewer — card with header */
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
|
||||
@@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -88,8 +88,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
@@ -122,13 +122,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
|
||||
{/* Multi choice toggle */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<div onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
|
||||
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer', border: 'none',
|
||||
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</div>
|
||||
</button>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
@@ -174,7 +174,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
{hover && createPortal(
|
||||
<div style={{
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarSrc(p.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,10 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
// Same pairs as before, written so no two quantifiers can claim the same
|
||||
// character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous,
|
||||
// which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab.
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function CollectionPlaceDetail({
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
@@ -176,7 +176,7 @@ export default function CollectionPlaceDetail({
|
||||
const CatIcon = getCategoryIcon(place.category?.icon)
|
||||
|
||||
return (
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
|
||||
{banner && <img src={banner} alt="" />}
|
||||
<div className="col-detail-cover-scrim" />
|
||||
|
||||
@@ -9,9 +9,9 @@ import { transportIcon } from './FileManager.helpers'
|
||||
export function AssignModal(S: FileManagerState) {
|
||||
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setAssignFileId(null)}>
|
||||
<div style={{
|
||||
<div role="presentation" style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
|
||||
cursor: 'default',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: name?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -68,8 +68,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
|
||||
return (
|
||||
<div
|
||||
// Backdrop only — Escape and the header's close button do the same job for
|
||||
// the keyboard. Closing on the backdrop's own clicks (rather than letting
|
||||
// every child stop the bubble) keeps the chrome free of click handlers.
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
if (touchStart === null) return
|
||||
@@ -80,7 +84,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{file.original_name}
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
@@ -105,22 +109,22 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
</div>
|
||||
|
||||
{/* Main image + nav */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
<div role="presentation" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{fileIsVideo ? (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<div>
|
||||
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
|
||||
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
)}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{files.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }}>
|
||||
{files.map((f, i) => (
|
||||
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||
))}
|
||||
|
||||
@@ -34,10 +34,12 @@ export function MarkdownPreviewModal(S: FileManagerState) {
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -8,10 +8,12 @@ export function PdfPreviewModal(S: FileManagerState) {
|
||||
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -34,10 +34,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
className="group"
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
disabled={isTrash}
|
||||
aria-label={file.original_name}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8, padding: 0,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||
}}
|
||||
@@ -48,13 +51,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -63,12 +66,14 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
||||
)}
|
||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
disabled={isTrash}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer', textAlign: 'left', minWidth: 0 }}
|
||||
>
|
||||
{file.original_name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{file.description && (
|
||||
@@ -76,7 +81,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
{!!file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlaces.map(p => (
|
||||
@@ -87,7 +92,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
|
||||
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
{!!file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -72,10 +72,10 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
|
||||
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
|
||||
)}
|
||||
{filtered.map(u => (
|
||||
<div
|
||||
<button type="button"
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUserId(u.id)}
|
||||
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
|
||||
className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
|
||||
selectedUserId === u.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
|
||||
@@ -93,7 +93,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
{/* Click-away catcher — no semantics of its own; the trigger button
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -78,7 +78,8 @@ describe('EntryCard', () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onEdit } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
|
||||
|
||||
await user.click(container.querySelectorAll('button')[0])
|
||||
// The photo itself is a button now, so pick the menu trigger by its own styling.
|
||||
await user.click(container.querySelector('button[class*="bg-black/40"]') as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
@@ -110,7 +111,10 @@ describe('EntryCard', () => {
|
||||
it('FE-JRN-CARD-007: hides the menu entirely in read-only mode', () => {
|
||||
const { container } = mountCard(buildEntry({ photos: [buildPhoto(100)] }), true)
|
||||
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0)
|
||||
// Only the photo remains clickable; the menu trigger is gone.
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0].querySelector('img')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-008: renders mood, weather and tags in the meta row', () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
</button>
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
@@ -118,7 +118,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
</button>
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
@@ -185,6 +185,9 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
|
||||
className={`rounded-[18px] px-3.5 py-3 flex items-center gap-3 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:-translate-y-0.5 cursor-pointer' : ''}`}
|
||||
style={{ border: '1.5px dashed var(--vg-line2)', background: 'var(--vg-surf2)' }}
|
||||
>
|
||||
@@ -212,6 +215,9 @@ export function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?:
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined}
|
||||
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
||||
|
||||
@@ -328,8 +328,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||
{availableGalleryPhotos.map(gp => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={gp.id}
|
||||
aria-label={t('journey.editor.fromGallery')}
|
||||
onClick={async () => {
|
||||
if (entry.id > 0) {
|
||||
try {
|
||||
@@ -341,11 +343,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
setPhotos(prev => [...prev, gp])
|
||||
}
|
||||
}}
|
||||
className="relative w-full rounded-xl overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
style={{ paddingTop: '100%' }}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||
@@ -658,7 +660,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
{showLocationResults && locationResults.length > 0 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
|
||||
<div role="presentation" className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg overflow-hidden max-h-[240px] overflow-y-auto">
|
||||
{locationResults.map((r, i) => (
|
||||
<button type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState, useRef, type HTMLAttributes } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import JournalBody from './JournalBody'
|
||||
@@ -23,11 +23,24 @@ export function ExpandableStory({ story }: { story: string }) {
|
||||
}
|
||||
})
|
||||
|
||||
// The text block toggles the same clamp as the Show more / Show less buttons
|
||||
// below it. It only takes focus while there is something to toggle, so a
|
||||
// short story stays plain text instead of an empty stop in the tab order.
|
||||
const toggle: HTMLAttributes<HTMLDivElement> = clamped || expanded
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-expanded': expanded,
|
||||
onClick: () => setExpanded(e => !e),
|
||||
onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(v => !v) } },
|
||||
}
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
|
||||
{...toggle}
|
||||
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
||||
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
||||
|
||||
@@ -152,8 +152,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
{shownPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
|
||||
>
|
||||
{photo.media_type === 'video' && !photo.thumbnail_path ? (
|
||||
// Poster-less video (capture failed / unsupported codec): show a
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('JourneyDetailPage MapView', () => {
|
||||
|
||||
it('FE-COMP-JMAPVIEW-005: each row is numbered by its position across the whole journey', () => {
|
||||
renderMapView()
|
||||
const berlin = screen.getByText('Museumsinsel').closest('div[class*="cursor-pointer"]')!
|
||||
const berlin = screen.getByText('Museumsinsel').closest('button[class*="cursor-pointer"]')!
|
||||
expect(within(berlin as HTMLElement).getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -161,9 +161,9 @@ describe('JourneyDetailPage MapView', () => {
|
||||
renderMapView({ activeLocationId: '2' })
|
||||
expect(lastMapProps.current.activeMarkerId).toBe('2')
|
||||
|
||||
const row = screen.getByText('Sacré-Cœur').closest('div[class*="cursor-pointer"]') as HTMLElement
|
||||
const row = screen.getByText('Sacré-Cœur').closest('button[class*="cursor-pointer"]') as HTMLElement
|
||||
expect(row.className).toContain('border-zinc-900')
|
||||
const inactive = screen.getByText('Louvre').closest('div[class*="cursor-pointer"]') as HTMLElement
|
||||
const inactive = screen.getByText('Louvre').closest('button[class*="cursor-pointer"]') as HTMLElement
|
||||
expect(inactive.className).toContain('border-zinc-200')
|
||||
})
|
||||
|
||||
|
||||
@@ -85,9 +85,10 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
|
||||
|
||||
return (
|
||||
<div key={e.id}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLocationClick(String(e.id))}
|
||||
className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
|
||||
className={`w-full text-left flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
|
||||
isActive
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
|
||||
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
|
||||
@@ -114,7 +115,7 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={14} className={`flex-shrink-0 ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Connector line */}
|
||||
{showConnector && (
|
||||
|
||||
@@ -2,15 +2,14 @@ import { Image } from 'lucide-react'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
|
||||
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
|
||||
const src = photoUrl(photo, 'thumbnail')
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
alt={photo.caption || ''}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
@@ -22,9 +21,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
|
||||
|
||||
if (count === 1) {
|
||||
return (
|
||||
<div className="overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
|
||||
<button type="button" className="block w-full overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
|
||||
<PhotoImg photo={photos[0]} className="w-full h-72 object-cover" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +31,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
||||
{photos.slice(0, 2).map((p, i) => (
|
||||
<PhotoImg key={p.id} photo={p} className="w-full h-52 object-cover cursor-pointer" onClick={() => onClick(i)} />
|
||||
<button key={p.id} type="button" className="block w-full h-52 overflow-hidden cursor-pointer" onClick={() => onClick(i)}>
|
||||
<PhotoImg photo={p} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -40,14 +41,14 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex" style={{ height: 300, gap: 2 }}>
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
|
||||
<button type="button" className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
|
||||
<PhotoImg photo={photos[0]} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
|
||||
<button type="button" className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
|
||||
<PhotoImg photo={photos[1]} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
|
||||
</button>
|
||||
<button type="button" className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
|
||||
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
|
||||
{count > 3 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
||||
@@ -55,7 +56,7 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
|
||||
+{count - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -144,6 +144,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
>
|
||||
@@ -152,6 +153,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -274,7 +276,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setAddToOpen(false)} />
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
@@ -365,10 +367,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={asset.id}
|
||||
disabled={alreadyAdded}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
@@ -402,7 +408,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -87,8 +87,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div role="presentation" className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
@@ -278,7 +278,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
{/* Add Trip — sits inside the backdrop, so its clicks have to be kept
|
||||
from bubbling into the close/discard handler underneath */}
|
||||
{showAddTrip && (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<AddTripDialog
|
||||
journeyId={journey.id}
|
||||
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
|
||||
|
||||
@@ -36,11 +36,12 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
|
||||
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
|
||||
if (!link) return
|
||||
const previous = link
|
||||
const updated = { ...link, [key]: !link[key] }
|
||||
setLink(updated)
|
||||
try {
|
||||
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
|
||||
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
|
||||
} catch { setLink(previous); toast.error(t('journey.share.updateFailed')) }
|
||||
}
|
||||
|
||||
const deleteLink = async () => {
|
||||
|
||||
@@ -91,12 +91,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
aria-label={photos[0].caption || t('journey.photos')}
|
||||
className="block w-full bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover"
|
||||
/>
|
||||
</button>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
@@ -107,13 +113,19 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
<button
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
type="button"
|
||||
aria-label={p.caption || t('journey.photos')}
|
||||
className="flex-shrink-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,10 @@ export default function VideoPlayer({
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata" />
|
||||
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata">
|
||||
{/* Gallery uploads carry no caption file; the empty track keeps the element valid. */}
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
export function stripMarkdown(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||
// the alt text is a run that cannot contain "](", so it ends on the first one
|
||||
// instead of being retried against every later "](". Same matches, no backtracking
|
||||
.replace(/!\[(?:(?!\]\().)*\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||
|
||||
@@ -304,9 +304,20 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t.close}
|
||||
onClick={() => setDismissed(true)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDismissed(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: 20,
|
||||
|
||||
@@ -69,7 +69,8 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
|
||||
{open && createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
|
||||
@@ -59,11 +59,12 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||
const handleLogout = () => { onClose(); logout(); navigate('/login') }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[300] md:hidden" role="presentation" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
||||
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
|
||||
@@ -272,7 +272,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{userMenuOpen && createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} role="presentation" onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999 }}>
|
||||
<div className="px-4 py-3 border-b border-edge-secondary">
|
||||
<p className="text-sm font-medium text-content">{user.username}</p>
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function PageSidebar({
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-[rgba(0,0,0,0.35)]"
|
||||
role="presentation"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
|
||||
@@ -27,7 +27,7 @@ export function decodePolyline(encoded: string, precision = 6): [number, number]
|
||||
let byte = 0x20
|
||||
while (byte >= 0x20) {
|
||||
if (index >= encoded.length) return coords
|
||||
byte = encoded.charCodeAt(index++) - 63
|
||||
byte = encoded.codePointAt(index++) - 63
|
||||
result |= (byte & 0x1f) << shift
|
||||
shift += 5
|
||||
}
|
||||
|
||||
@@ -130,9 +130,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
@@ -152,9 +150,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
style={{
|
||||
background: notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
|
||||
@@ -77,9 +77,9 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-content">{label}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
||||
<div className="text-xs font-medium text-content">
|
||||
{label}
|
||||
<p className="text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -85,7 +85,7 @@ function escHtml(str) {
|
||||
// escHtml alone cannot stop a cover_image from closing the url() and appending its own
|
||||
// declaration. Percent-encoding is transparent to the fetch.
|
||||
function cssUrl(url) {
|
||||
return String(url).replace(/["'()\\\s]/g, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
return String(url).replace(/["'()\\\s]/g, c => '%' + c.codePointAt(0).toString(16).padStart(2, '0'))
|
||||
}
|
||||
|
||||
function absUrl(url) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-COMP-PACKING-001 to FE-COMP-PACKING-020
|
||||
import { vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -899,9 +899,10 @@ describe('PackingListPanel', () => {
|
||||
const packageBtn = container.querySelector('svg.lucide-package')?.closest('button');
|
||||
fireEvent.click(packageBtn!);
|
||||
|
||||
// Picker is open - find "Trolley" button inside the dropdown
|
||||
// The dropdown renders as an absolute positioned div inside the item row
|
||||
const trolleyBtn = await screen.findByRole('button', { name: /Trolley/ });
|
||||
// Picker is open - find "Trolley" button inside the dropdown. The bag
|
||||
// sidebar carries the same name on its own button, so scope the query to
|
||||
// the picker (the dropdown is a sibling of the package button).
|
||||
const trolleyBtn = await within(packageBtn!.parentElement!).findByRole('button', { name: /Trolley/ });
|
||||
fireEvent.click(trolleyBtn);
|
||||
|
||||
await waitFor(() => expect(putBody).toMatchObject({ bag_id: 7 }));
|
||||
|
||||
@@ -59,7 +59,8 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }}
|
||||
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} />
|
||||
) : (
|
||||
<span onClick={() => canEdit && setEditingName(true)} style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default' }}>{bag.name}</span>
|
||||
<button type="button" disabled={!canEdit} onClick={() => setEditingName(true)}
|
||||
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>{bag.name}</button>
|
||||
)}
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
@@ -74,10 +75,10 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
<span>kg</span>
|
||||
</>
|
||||
) : bag.weight_limit_grams ? (
|
||||
<span onClick={() => canEdit && setEditingLimit(true)} title={t('packing.bagLimit')}
|
||||
style={{ cursor: canEdit ? 'text' : 'default' }}>
|
||||
<button type="button" disabled={!canEdit} onClick={() => setEditingLimit(true)} title={t('packing.bagLimit')}
|
||||
style={{ cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, fontFamily: 'inherit', fontSize: 'inherit' }}>
|
||||
/ {(bag.weight_limit_grams / 1000).toFixed(1)} kg
|
||||
</span>
|
||||
</button>
|
||||
) : canEdit ? (
|
||||
<button type="button" onClick={() => setEditingLimit(true)} title={t('packing.bagLimit')}
|
||||
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-faint)', fontSize: sz.count, fontFamily: 'inherit', textDecoration: 'underline dotted' }}>
|
||||
@@ -89,17 +90,22 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
</div>
|
||||
{/* Members */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, flexWrap: 'wrap', position: 'relative' }}>
|
||||
{(bag.members || []).map(m => (
|
||||
<span key={m.user_id} title={m.username} onClick={() => canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}>
|
||||
{m.avatar ? (
|
||||
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
|
||||
) : (
|
||||
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(bag.members || []).map(m => {
|
||||
const face = m.avatar ? (
|
||||
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
|
||||
) : (
|
||||
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
if (!canEdit) return <span key={m.user_id} title={m.username} style={{ display: 'inline-flex' }}>{face}</span>
|
||||
return (
|
||||
<button type="button" key={m.user_id} title={m.username} aria-label={m.username} onClick={() => toggleMember(m.user_id)}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', background: 'none', border: 'none', padding: 0 }}>
|
||||
{face}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
|
||||
<Plus size={sz.avatar * 0.5} />
|
||||
|
||||
@@ -15,8 +15,13 @@ export function BagModal(S: PackingState) {
|
||||
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={() => setShowBagModal(false)}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowBagModal(false) }
|
||||
}}>
|
||||
<div role="presentation" style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
||||
|
||||
@@ -377,7 +377,10 @@ describe('KategorieGruppe — assignees', () => {
|
||||
})
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
|
||||
fireEvent.click(within(screen.getByRole('button', { name: /alice/ })).getByText('alice').closest('button')!)
|
||||
// Scoped to the dropdown: an assigned member also has a chip button carrying
|
||||
// their name, so an unscoped role query matches two elements.
|
||||
const dropdown = within(container.querySelector('svg.lucide-user-plus')!.closest('div')!)
|
||||
fireEvent.click(dropdown.getByRole('button', { name: /alice/ }))
|
||||
|
||||
expect(onSetAssignees).toHaveBeenCalledWith('Gear', [1])
|
||||
})
|
||||
@@ -388,8 +391,9 @@ describe('KategorieGruppe — assignees', () => {
|
||||
assignees: [{ user_id: 1, username: 'owner' }],
|
||||
})
|
||||
fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!)
|
||||
const assigned = screen.getByRole('button', { name: /owner/ })
|
||||
const free = screen.getByRole('button', { name: /alice/ })
|
||||
const dropdown = within(container.querySelector('svg.lucide-user-plus')!.closest('div')!)
|
||||
const assigned = dropdown.getByRole('button', { name: /owner/ })
|
||||
const free = dropdown.getByRole('button', { name: /alice/ })
|
||||
|
||||
fireEvent.mouseEnter(free)
|
||||
expect(free.style.background).toBe('var(--bg-tertiary)')
|
||||
|
||||
@@ -137,32 +137,43 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
|
||||
{/* Assignee chips */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||
{assignees.map(a => (
|
||||
<div key={a.user_id} style={{ position: 'relative' }}
|
||||
onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
>
|
||||
<div className="assignee-chip"
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
|
||||
}}
|
||||
{assignees.map(a => {
|
||||
// The chip is only ever clickable for an editor, so read-only members
|
||||
// get the plain div back rather than a focusable stop that does nothing.
|
||||
const chip = (
|
||||
<>
|
||||
<div className="assignee-chip"
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||
background: `hsl(${(a.username.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{a.username[0]}
|
||||
</div>
|
||||
<div className="assignee-tooltip" style={{
|
||||
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
|
||||
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{a.username}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!canEdit) return <div key={a.user_id} style={{ position: 'relative' }}>{chip}</div>
|
||||
return (
|
||||
<button type="button" key={a.user_id}
|
||||
style={{ position: 'relative', background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
>
|
||||
{a.username[0]}
|
||||
</div>
|
||||
<div className="assignee-tooltip" style={{
|
||||
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
|
||||
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{a.username}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{chip}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canEdit && (
|
||||
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||
@@ -204,7 +215,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
background: `hsl(${(m.username.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
}}>
|
||||
@@ -244,7 +255,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
const rect = menuBtnRef.current?.getBoundingClientRect();
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
|
||||
<div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
|
||||
@@ -9,12 +9,12 @@ export function BulkImportModal(S: PackingState) {
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowImportModal(false)}>
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowImportModal(false) }}>
|
||||
<div style={{
|
||||
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}}>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import { useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -94,6 +94,17 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||
const selectedBag = bags.find(b => b.id === item.bag_id)
|
||||
|
||||
// Shared by both shells of the name: a renameable name is a real button so
|
||||
// the rename can be reached with the keyboard, everything else is plain text.
|
||||
const nameStyle: CSSProperties = {
|
||||
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))',
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group packing-item-row"
|
||||
@@ -151,19 +162,17 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => canEdit && !item.checked && setEditing(true)}
|
||||
style={{
|
||||
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))',
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
) : canEdit && !item.checked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
style={{ ...nameStyle, border: 'none', background: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span style={nameStyle}>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -356,7 +365,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
const rect = itemMenuBtnRef.current?.getBoundingClientRect()
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1098 }} onClick={() => { setShowItemMenu(false); setShowMenuCategories(false) }} />
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 1098 }} onClick={() => { setShowItemMenu(false); setShowMenuCategories(false) }} />
|
||||
<div className="trek-menu-enter" style={{
|
||||
position: 'fixed',
|
||||
right: rect ? Math.max(8, window.innerWidth - rect.right) : 8,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PackingViewTabs(S: PackingState) {
|
||||
const viewPill = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
|
||||
const active = view === id
|
||||
return (
|
||||
<button onClick={() => setView(id)} style={{
|
||||
<button type="button" onClick={() => setView(id)} style={{
|
||||
...pillBase,
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
@@ -39,7 +39,7 @@ export function PackingViewTabs(S: PackingState) {
|
||||
const filterPill = (id: string, label: string) => {
|
||||
const active = filter === id
|
||||
return (
|
||||
<button key={id} onClick={() => setFilter(id)} style={{
|
||||
<button type="button" key={id} onClick={() => setFilter(id)} style={{
|
||||
...pillBase, gap: 0, border: '1px solid transparent', fontWeight: active ? 600 : 400,
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function PackingShareControl({ item, tripMembers, currentUserId,
|
||||
</button>
|
||||
{open && pos && createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1099 }} onClick={() => setOpen(false)} />
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 1099 }} onClick={() => setOpen(false)} />
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, right: pos.right, zIndex: 1100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
@@ -101,7 +101,7 @@ export default function PackingShareControl({ item, tripMembers, currentUserId,
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: 'none', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', flexShrink: 0, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase' }}>{m.username[0]}</span>
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', flexShrink: 0, background: `hsl(${(m.username.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase' }}>{m.username[0]}</span>
|
||||
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
|
||||
{on && <Check size={13} className="text-content-muted" />}
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ export function katColor(kat: string, allCategories?: string[]): string {
|
||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
||||
// Fallback: hash-based
|
||||
let h = 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + (kat.codePointAt(i) ?? 0)) | 0
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +311,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
role="presentation"
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||
@@ -318,6 +319,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
|
||||
@@ -121,6 +122,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
@@ -149,7 +151,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
@@ -173,7 +176,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
|
||||
@@ -144,8 +144,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden', maxHeight: collapsed ? 'none' : '60vh', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: collapsed ? '12px 16px 12px 20px' : '18px 16px 14px 20px', borderBottom: collapsed ? 'none' : '1px solid var(--border-faint)', cursor: 'pointer' }}
|
||||
{/* Header. Clicking the bar collapses the panel, but that is a mouse
|
||||
shortcut for the chevron button below, a real button with a title that
|
||||
does the same thing. So the bar declares itself presentational instead
|
||||
of becoming a second, unlabelled tab stop wrapped around the rename
|
||||
input and the two icon buttons. */}
|
||||
<div role="presentation" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: collapsed ? '12px 16px 12px 20px' : '18px 16px 14px 20px', borderBottom: collapsed ? 'none' : '1px solid var(--border-faint)', cursor: 'pointer' }}
|
||||
onClick={() => toggleCollapse()}>
|
||||
<div className="bg-surface-secondary" style={{ width: collapsed ? 36 : 44, height: collapsed ? 36 : 44, borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all 0.15s ease' }}>
|
||||
<Calendar size={collapsed ? 16 : 20} className="text-content" />
|
||||
@@ -200,7 +204,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ overflowY: 'auto', padding: '14px 20px 18px', display: collapsed ? 'none' : 'block' }}>
|
||||
|
||||
{/* ── Weather ── */}
|
||||
{day.date && lat && lng && (
|
||||
{!!(day.date && lat && lng) && (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 16, color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
<div style={{ width: 18, height: 18, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 6px' }} />
|
||||
@@ -285,7 +289,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
if (dayReservations.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
|
||||
{!!(day.date && lat && lng) && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{dayReservations.map(r => {
|
||||
@@ -400,7 +404,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{acc.place_image ? (
|
||||
<img src={acc.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
||||
<img src={acc.place_image} alt="" style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
@@ -454,12 +458,17 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
{linked.confirmation_number && <span
|
||||
{/* Reveal toggle for a blurred code: a real button, so it can
|
||||
be reached by keyboard. With blurring off there is nothing
|
||||
to toggle, and it stays out of the tab order. */}
|
||||
{linked.confirmation_number && <button
|
||||
type="button"
|
||||
disabled={!blurCodes}
|
||||
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||
>#{linked.confirmation_number}</span>}
|
||||
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default', background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'inherit' }}
|
||||
>#{linked.confirmation_number}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,9 +544,12 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
|
||||
<>
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
{showHotelPicker && createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
// Click screen and the card that stops the click from reaching it:
|
||||
// neither is a control, and the popup has its own X and Cancel
|
||||
// buttons, so both stay presentational.
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setShowHotelPicker(false)}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
<div role="presentation" onClick={e => e.stopPropagation()} style={{
|
||||
width: '100%', maxWidth: 900, borderRadius: 16, overflow: 'hidden',
|
||||
background: 'var(--bg-card)', boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
...font,
|
||||
@@ -655,7 +667,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{p.image_url ? (
|
||||
<img src={p.image_url} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
|
||||
<img src={p.image_url} alt="" style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<MapPin size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
)}
|
||||
|
||||
@@ -1466,6 +1466,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
|
||||
])
|
||||
}
|
||||
|
||||
// Enter/Space on a route connector opens the same mode menu a click opens. A key
|
||||
// event carries no pointer position, so the menu is anchored to the connector.
|
||||
const openLegMenuByKey = (e: React.KeyboardEvent<HTMLDivElement>, open: (m: React.MouseEvent) => void) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
open({
|
||||
clientX: rect.left,
|
||||
clientY: rect.bottom,
|
||||
preventDefault: () => e.preventDefault(),
|
||||
stopPropagation: () => e.stopPropagation(),
|
||||
} as unknown as React.MouseEvent)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||
{/* Toolbar */}
|
||||
@@ -1500,6 +1514,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
// Shared by the click and the key handler so the two can never drift
|
||||
// apart — pressing Enter on the row has to toggle, not just select.
|
||||
// Named for the selection, not the expand/collapse `toggleDay` above.
|
||||
const toggleDaySelection = () => {
|
||||
if (isSelected) { onSelectDay(null); if (onDayDetail) onDayDetail(null) }
|
||||
else { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }
|
||||
}
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
const da = getDayAssignments(day.id)
|
||||
const cost = dayTotalCost(day.id, assignments, costBase, currency, locale, fxRates)
|
||||
@@ -1580,7 +1601,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
className="dp-day-header"
|
||||
data-selected={isSelected}
|
||||
// Clicking the selected day again deselects it — same as the day panel's × (#2024).
|
||||
onClick={() => { if (isSelected) { onSelectDay(null); if (onDayDetail) onDayDetail(null) } else { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) } }}
|
||||
// Selecting the day has no other trigger, so the row carries the
|
||||
// button role itself. The badges inside it are buttons of their
|
||||
// own, hence the key handler only answers for the row — and it
|
||||
// toggles exactly like the click, rather than only selecting.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleDaySelection()}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
toggleDaySelection()
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
@@ -1686,10 +1719,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const isCheckOut = acc.end_day_id === day.id
|
||||
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
|
||||
return (
|
||||
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||
<button type="button" key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px', border: 'none', font: 'inherit', textAlign: 'left' }}>
|
||||
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
@@ -1698,10 +1731,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const activeRentals = getActiveRentalsForDay(day.id)
|
||||
if (activeRentals.length === 0) return null
|
||||
return activeRentals.map(r => (
|
||||
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||
<button type="button" key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px', border: 'none', font: 'inherit', textAlign: 'left' }}>
|
||||
<Car size={11} strokeWidth={1.8} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
@@ -1721,19 +1754,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{/* Public transit search (#1065) — replaced the rename pencil,
|
||||
which moved next to the day name in the day detail view. */}
|
||||
{onPlanTransit ? (
|
||||
<button onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<TramFront size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : <div style={{ borderRight: div, borderBottom: div }} />}
|
||||
{onAddTransport ? (
|
||||
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : <div style={{ borderBottom: div }} />}
|
||||
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||
<button type="button" onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||
<FileText size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||
<button type="button" onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1816,7 +1849,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.top?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
@@ -1890,11 +1923,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}
|
||||
const moveUp = (e) => { e.stopPropagation(); arrowMove('up') }
|
||||
const moveDown = (e) => { e.stopPropagation(); arrowMove('down') }
|
||||
const selectPlace = () => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }
|
||||
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
<div
|
||||
className="dp-row"
|
||||
// Picking the place out of the day has no other trigger, so
|
||||
// the row is the control. Its grip, lock and arrows are
|
||||
// buttons in their own right, hence the key handler only
|
||||
// answers for the row itself.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable={canEditDays && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || dragDisabled) { e.preventDefault(); return }
|
||||
@@ -1950,7 +1990,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onClick={selectPlace}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
selectPlace()
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
// Flat entries, one per app: the menu has room to
|
||||
// grow and a submenu would need a component that
|
||||
@@ -1998,11 +2044,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={lockedIds.has(assignment.id) ? t('planner.clickToUnlock') : t('planner.keepPosition')}
|
||||
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||
onMouseLeave={() => setLockHoverId(null)}
|
||||
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
|
||||
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer', display: 'flex', background: 'none', border: 'none', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={28} />
|
||||
{/* Hover/locked overlay */}
|
||||
@@ -2030,7 +2078,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
: t('planner.keepPosition')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
@@ -2145,7 +2193,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{p.avatar ? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
{p.avatar ? <img src={avatarSrc(p.avatar)!} alt={p.username ?? ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{assignment.participants.length > 5 && (
|
||||
@@ -2191,7 +2239,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
|
||||
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }}>
|
||||
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -2247,22 +2295,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||
|
||||
const openTransportRow = () => {
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
// A transit journey opens its own journey view — the rich
|
||||
// stop-by-stop breakdown with its booking fields, never the
|
||||
// generic edit form (#1065).
|
||||
if (transitMeta) {
|
||||
if (onOpenTransit) onOpenTransit(target)
|
||||
else setTransportDetail(target)
|
||||
return
|
||||
}
|
||||
if (!canEditDays) return
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||
else onEditReservation?.(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
// A transit journey opens its own journey view — the rich
|
||||
// stop-by-stop breakdown with its booking fields, never the
|
||||
// generic edit form (#1065).
|
||||
if (transitMeta) {
|
||||
if (onOpenTransit) onOpenTransit(target)
|
||||
else setTransportDetail(target)
|
||||
return
|
||||
}
|
||||
if (!canEditDays) return
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||
else onEditReservation?.(target)
|
||||
// Opening the booking has no other trigger, so the row is
|
||||
// the control; the buttons inside it answer for themselves.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openTransportRow}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
openTransportRow()
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
@@ -2433,7 +2493,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
|
||||
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
|
||||
return canEditDays && nextPlaceId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
@@ -2567,7 +2627,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.bottom?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
|
||||
@@ -69,12 +69,12 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => cancelNote(Number(dayId))}>
|
||||
}} role="presentation" onClick={() => cancelNote(Number(dayId))}>
|
||||
<div className="bg-surface-card note-modal" style={{
|
||||
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
|
||||
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
|
||||
</div>
|
||||
|
||||
@@ -22,12 +22,12 @@ interface DayPlanSidebarTimeConfirmModalProps {
|
||||
export function DayPlanSidebarTimeConfirmModal({ timeConfirm, setTimeConfirm, confirmTimeRemoval, t }: DayPlanSidebarTimeConfirmModalProps) {
|
||||
if (!timeConfirm) return null
|
||||
return createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
<div role="presentation" className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTimeConfirm(null)}>
|
||||
<div className="bg-surface-card" style={{
|
||||
<div role="presentation" className="bg-surface-card" style={{
|
||||
width: 340, borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
|
||||
@@ -30,8 +30,14 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTransportDetail(null)}>
|
||||
<div className="bg-surface-card" style={{
|
||||
}}
|
||||
role="button" tabIndex={0} aria-label={t('common.cancel')}
|
||||
onClick={() => setTransportDetail(null)}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTransportDetail(null) }
|
||||
}}>
|
||||
<div className="bg-surface-card" role="presentation" style={{
|
||||
width: 380, maxHeight: '80vh', overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
@@ -109,21 +115,29 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{detailFields.map((f, i) => {
|
||||
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
|
||||
const valueStyle = {
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, wordBreak: 'break-word',
|
||||
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||
cursor: shouldBlur ? 'pointer' : 'default',
|
||||
userSelect: shouldBlur ? 'none' : 'auto',
|
||||
} as const
|
||||
return (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
<div
|
||||
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
|
||||
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
|
||||
className="text-content"
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, wordBreak: 'break-word',
|
||||
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||
cursor: shouldBlur ? 'pointer' : 'default',
|
||||
userSelect: shouldBlur ? 'none' : 'auto',
|
||||
}}
|
||||
>{f.value}</div>
|
||||
{/* A hidden booking code is a reveal toggle, so it has to be a real
|
||||
button; a plain value stays inert text and out of the tab order. */}
|
||||
{shouldBlur ? (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={e => { e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.filter = 'blur(5px)' }}
|
||||
onClick={e => { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' }}
|
||||
className="text-content"
|
||||
style={{ ...valueStyle, display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: 0, fontFamily: 'inherit' }}
|
||||
>{f.value}</button>
|
||||
) : (
|
||||
<div className="text-content" style={valueStyle}>{f.value}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -215,12 +229,13 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{resFiles.map(f => (
|
||||
<div key={f.id}
|
||||
<button key={f.id}
|
||||
type="button"
|
||||
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
|
||||
className="bg-surface-tertiary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
|
||||
borderRadius: 8, cursor: 'pointer',
|
||||
borderRadius: 8, cursor: 'pointer', border: 'none', textAlign: 'left', fontFamily: 'inherit',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
@@ -231,7 +246,7 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
{f.original_name}
|
||||
</span>
|
||||
<ExternalLink size={11} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
@@ -131,6 +132,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
|
||||
@@ -217,11 +217,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={handleClose}
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }}
|
||||
@@ -242,7 +244,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
@@ -277,7 +280,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
@@ -285,7 +288,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<button type="button" key={key} role="checkbox" aria-checked={gpxOpts[key]} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<div className={gpxOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
@@ -296,7 +299,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
@@ -310,7 +313,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<button type="button" key={key} role="checkbox" aria-checked={kmlOpts[key]} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer', width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<div className={kmlOpts[key] ? 'bg-accent' : 'bg-transparent'} style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
@@ -321,7 +324,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div className="text-[#b45309]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ export function IcsSubscribeModal({ endpoint, title, description, onClose }: Ics
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
|
||||
@@ -838,7 +838,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
onValueChange={v => handleChange('lat', v)}
|
||||
onPaste={e => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) {
|
||||
e.preventDefault()
|
||||
handleChange('lat', match[1])
|
||||
@@ -913,7 +913,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
||||
creating, and for unassigned / multi-day pool edits where a single time
|
||||
is ambiguous and wouldn't persist. */}
|
||||
{place && assignmentId && (
|
||||
{!!(place && assignmentId) && (
|
||||
<TimeSection
|
||||
form={form}
|
||||
handleChange={handleChange}
|
||||
|
||||
@@ -1291,7 +1291,7 @@ describe('PlaceInspector', () => {
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-093: participant chips and the add menu reset their hover styling', () => {
|
||||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} />);
|
||||
const chip = screen.getByText('ada').closest('div') as HTMLElement;
|
||||
const chip = screen.getByText('ada').closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(chip);
|
||||
expect(chip.className).toContain('text-[#ef4444]');
|
||||
fireEvent.mouseLeave(chip);
|
||||
|
||||
@@ -484,7 +484,7 @@ export default function PlaceInspector({
|
||||
<StatusBadge status={collectionStatus} onChange={onSetStatus} t={t} />
|
||||
)}
|
||||
{/* Trip mode — day assignment */}
|
||||
{mode === 'trip' && selectedDayId && (
|
||||
{mode === 'trip' && !!selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
|
||||
@@ -649,7 +649,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
const isHovered = hoveredId === member.id
|
||||
const canRemove = activeMembers.length > 1
|
||||
return (
|
||||
<div key={member.id}
|
||||
<button type="button" key={member.id} disabled={!canRemove}
|
||||
onMouseEnter={() => setHoveredId(member.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() => { if (canRemove) handleRemove(member.id) }}
|
||||
@@ -661,15 +661,15 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
cursor: canRemove ? 'pointer' : 'default',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<div className="bg-surface-tertiary text-content-muted" style={{
|
||||
<span className="bg-surface-tertiary text-content-muted" style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700,
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -706,7 +706,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || avatarSrc(member.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{member.username}</span>
|
||||
{member.is_guest && <GuestBadge size="xs" />}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
|
||||
const { t } = useTranslation()
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
@@ -53,7 +54,7 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }:
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<button type="button" onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
|
||||
@@ -10,11 +10,13 @@ export function ListImportModal(S: SidebarState) {
|
||||
} = S
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||
|
||||
@@ -9,10 +9,12 @@ export function MobileDayPickerSheet(S: SidebarState) {
|
||||
} = S
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
|
||||
@@ -35,10 +35,22 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
// Touch is reached through a long press instead of being locked out (#1616).
|
||||
const dragDisabled = isMobile
|
||||
// One place for what a row does, so the keyboard path below cannot drift from the click.
|
||||
const activate = () => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
ref={element => registerPlaceRow(place.id, element)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
data-place-id={place.id}
|
||||
draggable={!selectMode && !dragDisabled}
|
||||
@@ -48,14 +60,11 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
onClick={activate}
|
||||
onKeyDown={e => {
|
||||
// Only when the row itself has focus — the "+" button inside it keeps its own key handling.
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() }
|
||||
}}
|
||||
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
|
||||
style={{
|
||||
@@ -120,7 +129,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!selectMode && !inDay && selectedDayId && (
|
||||
{!selectMode && !inDay && selectedDayId !== null && (
|
||||
<button type="button"
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="bg-surface-hover text-content-faint"
|
||||
|
||||
@@ -677,7 +677,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
// Click the X to unlink
|
||||
const fileRow = screen.getByText('receipt.pdf').closest('div')!;
|
||||
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
|
||||
// Two buttons per row: [0] opens the file, [1] is the unlink X.
|
||||
const unlinkBtn = fileRow.querySelectorAll('button[type="button"]')[1];
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
// File removed from attached list and "Link existing file" button reappears
|
||||
@@ -1317,7 +1318,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
render(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
const row = screen.getByText('voucher.pdf').closest('div') as HTMLElement;
|
||||
await userEvent.click(within(row).getByRole('button'));
|
||||
await userEvent.click(within(row).getAllByRole('button')[1]);
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
||||
delete window.__addToast;
|
||||
@@ -1404,14 +1405,14 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-086: the open-file link on an attached file does not throw', async () => {
|
||||
it('FE-PLANNER-RESMODAL-086: the open-file button on an attached file does not throw', async () => {
|
||||
const res = buildReservation({ id: 16, type: 'other', title: 'Trip' });
|
||||
const attached = buildTripFile({ id: 80, original_name: 'ticket.pdf' });
|
||||
(attached as unknown as { reservation_id: number }).reservation_id = 16;
|
||||
|
||||
render(<ReservationModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
const row = screen.getByText('ticket.pdf').closest('div') as HTMLElement;
|
||||
await userEvent.click(within(row).getByRole('link'));
|
||||
await userEvent.click(within(row).getAllByRole('button')[0]);
|
||||
// The download is best-effort; the row must survive a failed fetch.
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -698,7 +698,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}><ExternalLink size={11} /></button>
|
||||
<button type="button" onClick={async () => {
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
|
||||
|
||||
@@ -77,6 +77,35 @@ function buildAssignmentLookup(days, assignments) {
|
||||
const fieldLabelClass = 'text-[10px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[5px]'
|
||||
const fieldValueClass = 'text-[13px] font-medium text-content px-[10px] py-[8px] bg-surface-tertiary rounded-[10px]'
|
||||
|
||||
// A booking code is only a control while the blur setting is on: then it lifts on
|
||||
// hover and toggles on click or Enter. With blurring off it is plain text, so it
|
||||
// stays a plain element instead of a button that can never do anything.
|
||||
function ConfirmationCode({ code, blurred, revealed, onReveal }: {
|
||||
code: React.ReactNode
|
||||
blurred: boolean
|
||||
revealed: boolean
|
||||
onReveal: (next: boolean | ((v: boolean) => boolean)) => void
|
||||
}) {
|
||||
const codeStyle: CSSProperties = {
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
filter: blurred && !revealed ? 'blur(5px)' : 'none',
|
||||
transition: 'filter 0.2s',
|
||||
}
|
||||
if (!blurred) return <div className={`${fieldValueClass} text-center`} style={{ ...codeStyle, cursor: 'default' }}>{code}</div>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${fieldValueClass} text-center`}
|
||||
style={{ ...codeStyle, cursor: 'pointer', width: '100%' }}
|
||||
onMouseEnter={() => onReveal(true)}
|
||||
onMouseLeave={() => onReveal(false)}
|
||||
onClick={() => onReveal(v => !v)}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
@@ -298,20 +327,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{hasCode && (
|
||||
<div>
|
||||
<div className={fieldLabelClass}>{t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
className={`${fieldValueClass} text-center`}
|
||||
style={{
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
<ConfirmationCode code={r.confirmation_number} blurred={!!blurCodes} revealed={codeRevealed} onReveal={setCodeRevealed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -350,20 +366,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{coded.map((l, i) => (
|
||||
<div key={i}>
|
||||
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
className={`${fieldValueClass} text-center`}
|
||||
style={{
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{l.confirmation_number}
|
||||
</div>
|
||||
<ConfirmationCode code={l.confirmation_number} blurred={!!blurCodes} revealed={codeRevealed} onReveal={setCodeRevealed} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -463,10 +466,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div className={fieldLabelClass}>{t('files.title')}</div>
|
||||
<div className={fieldValueClass} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 10px' }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<button key={f.id} type="button" onClick={() => { openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<FileText size={11} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,12 +500,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{showDeleteConfirm && createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" role="presentation" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div className="bg-surface-card" style={{
|
||||
<div className="bg-surface-card" role="presentation" style={{
|
||||
width: 340, borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
@@ -610,6 +613,9 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
<div
|
||||
className="bg-surface-card"
|
||||
onClick={() => onOpen(r)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
|
||||
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
@@ -658,7 +664,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
</div>
|
||||
)}
|
||||
{r.travelers && r.travelers.length > 0 && (
|
||||
<div style={{ paddingLeft: 44 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ paddingLeft: 44 }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<TravelerAvatarRow travelers={r.travelers} />
|
||||
</div>
|
||||
)}
|
||||
@@ -666,7 +672,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
{/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
|
||||
card itself is clickable, so keep frame interactions from opening it. */}
|
||||
{detailPlugins.length > 0 && (
|
||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div role="presentation" onClick={e => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{detailPlugins.map(p => (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={String(tripId)} reservationId={String(r.id)} title={p.name} surface="detail-slot" />
|
||||
@@ -675,8 +681,8 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
</div>
|
||||
)}
|
||||
{confirmOpen && createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.35)]" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
|
||||
<div className="bg-surface-card" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-[rgba(0,0,0,0.35)]" role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
|
||||
<div className="bg-surface-card" role="presentation" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -957,13 +963,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{t(addManualKey)}
|
||||
</button>
|
||||
{onImport && bookingImportAvailable && (
|
||||
<button onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<button type="button" onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<Download size={14} strokeWidth={2} />
|
||||
{t('reservations.import.cta')}
|
||||
</button>
|
||||
)}
|
||||
{onAirTrailImport && airTrailAvailable && (
|
||||
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<button type="button" onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<Plane size={14} strokeWidth={2} />
|
||||
{t('reservations.airtrail.cta')}
|
||||
</button>
|
||||
|
||||
@@ -539,9 +539,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3, width: isMobile ? '100%' : undefined }}>
|
||||
<button onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
|
||||
<button onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
|
||||
<button onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
|
||||
<button type="button" onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
|
||||
<button type="button" onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
|
||||
<button type="button" onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -307,7 +307,8 @@ describe('TransportModal', () => {
|
||||
);
|
||||
|
||||
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
|
||||
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
|
||||
// The row carries two buttons: [0] opens the file, [1] unlinks it.
|
||||
const unlinkBtn = fileRow.querySelectorAll('button[type="button"]')[1];
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1080,7 +1081,7 @@ describe('TransportModal', () => {
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
|
||||
await userEvent.click(within(row).getByRole('button'));
|
||||
await userEvent.click(within(row).getAllByRole('button')[1]);
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
||||
delete window.__addToast;
|
||||
@@ -1186,7 +1187,7 @@ describe('TransportModal', () => {
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
||||
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
|
||||
await userEvent.click(within(row).getByRole('link'));
|
||||
await userEvent.click(within(row).getByRole('button', { name: /open/i }));
|
||||
expect(screen.getByText('boarding.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -55,11 +55,19 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se
|
||||
}
|
||||
}
|
||||
|
||||
// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim
|
||||
// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading
|
||||
// \s* backtracks over every space in a long name for a quadratic worst case.
|
||||
function stripAirportCode(name: string): string {
|
||||
const trimmed = name.trimEnd()
|
||||
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name
|
||||
}
|
||||
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return {
|
||||
iata: e.code, icao: null,
|
||||
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
|
||||
name: e.name, city: stripAirportCode(e.name),
|
||||
country: '',
|
||||
lat: e.lat, lng: e.lng,
|
||||
tz: e.timezone || '',
|
||||
@@ -1011,7 +1019,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
||||
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
||||
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} className="text-content-faint" style={{ display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} aria-label={t('common.open')} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 0, display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></button>
|
||||
<button type="button" onClick={async () => {
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
|
||||
|
||||
@@ -15,10 +15,16 @@ export function convertHoursLine(line: string, timeFormat: string): string {
|
||||
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
|
||||
|
||||
if (timeFormat === '12h' && !hasAmPm) {
|
||||
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
|
||||
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
|
||||
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen.
|
||||
// split/trimEnd rather than /\s*Uhr/g: that pattern walks the rest of the line
|
||||
// from every space it passes, so a line with no "Uhr" in it costs a pass per
|
||||
// space. Cutting on the word and trimming the piece in front of it is the
|
||||
// same edit, once.
|
||||
const parts = line.split('Uhr')
|
||||
const withoutUhr = parts.map((part, i) => (i < parts.length - 1 ? part.trimEnd() : part)).join('')
|
||||
return withoutUhr.replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
|
||||
const hour = Number.parseInt(h)
|
||||
if (isNaN(hour)) return match
|
||||
if (Number.isNaN(hour)) return match
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
||||
return `${h12}:${m} ${period}`
|
||||
|
||||
@@ -108,8 +108,8 @@ export function PluginActions({ items, tripId, className }: { items: ViewContrib
|
||||
</button>
|
||||
))}
|
||||
{frame && (
|
||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center bg-black/50 p-4" onClick={() => setFrame(null)}>
|
||||
<div className="bg-surface-card rounded-xl shadow-xl w-full max-w-lg h-[70vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-[4000] flex items-center justify-center bg-black/50 p-4" onClick={() => setFrame(null)}>
|
||||
<div role="presentation" className="bg-surface-card rounded-xl shadow-xl w-full max-w-lg h-[70vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-edge shrink-0">
|
||||
<span className="text-sm font-medium text-content truncate">{frame.label}</span>
|
||||
<button type="button" onClick={() => setFrame(null)} className="text-content-muted hover:text-content"><X size={16} /></button>
|
||||
|
||||
@@ -13,6 +13,14 @@ import PasskeysSection from './PasskeysSection'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
// Drops every trailing slash, like the `/\/+$/` replace it stands in for — as a scan,
|
||||
// because that pattern re-walks the slash run from each start position.
|
||||
const stripTrailingSlashes = (value: string): string => {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end--
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -463,7 +471,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
{t('settings.oidcLinked')} {stripTrailingSlashes((user as UserWithOidc).oidc_issuer!.replace('https://', ''))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -508,11 +516,11 @@ export default function AccountTab(): React.ReactElement {
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div className="bg-[#fef3c7]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} className="text-[#d97706]" />
|
||||
@@ -544,11 +552,11 @@ export default function AccountTab(): React.ReactElement {
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div className="bg-[#fef2f2]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} className="text-[#ef4444]" />
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
const accentLight = cfg.accent?.light ?? '#4f46e5'
|
||||
const accentDark = cfg.accent?.dark ?? '#6366f1'
|
||||
const customRatio = contrastRatio(isDark ? accentDark : accentLight, isDark ? '#ffffff' : '#ffffff')
|
||||
const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -416,6 +416,7 @@ function IntegrationsMcpSection(props: any) {
|
||||
))}
|
||||
{client.allowed_scopes.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
|
||||
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
@@ -549,7 +550,7 @@ function McpTokenModals(props: any) {
|
||||
<>
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
|
||||
{!mcpCreatedToken ? (
|
||||
@@ -605,7 +606,7 @@ function McpTokenModals(props: any) {
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
@@ -636,7 +637,7 @@ function OAuthClientModals(props: any) {
|
||||
<>
|
||||
{/* Create OAuth Client modal */}
|
||||
{oauthCreateOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh] bg-surface-card">
|
||||
{!oauthCreatedClient ? (
|
||||
@@ -670,12 +671,12 @@ function OAuthClientModals(props: any) {
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||
<label htmlFor="oauth-machine-client" className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input id="oauth-machine-client" type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-content-secondary">{t('settings.oauth.modal.machineClient')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||
<div className="text-sm font-medium text-content-secondary">
|
||||
{t('settings.oauth.modal.machineClient')}
|
||||
<p className="text-xs mt-0.5 font-normal" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -763,7 +764,7 @@ function OAuthClientModals(props: any) {
|
||||
|
||||
{/* Delete OAuth Client confirm */}
|
||||
{oauthDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('settings.oauth.deleteClient')}</h3>
|
||||
@@ -784,7 +785,7 @@ function OAuthClientModals(props: any) {
|
||||
|
||||
{/* Rotate OAuth Client Secret confirm */}
|
||||
{oauthRotateId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('settings.oauth.rotateSecret')}</h3>
|
||||
@@ -836,7 +837,7 @@ function OAuthClientModals(props: any) {
|
||||
|
||||
{/* Revoke OAuth Session confirm */}
|
||||
{oauthRevokeId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('settings.oauth.revokeSession')}</h3>
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date(),
|
||||
created_at: String(new Date()),
|
||||
}], [])
|
||||
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
|
||||
@@ -431,6 +431,7 @@ export function StudioCanvas({
|
||||
</button>
|
||||
<span className="st-quickbar-sep" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
|
||||
@@ -352,6 +352,7 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
|
||||
<div className="st-grid-grid">
|
||||
{GRIDS.map(grid => (
|
||||
<button
|
||||
type="button"
|
||||
key={grid.id}
|
||||
className="st-grid-btn"
|
||||
onClick={() => {
|
||||
|
||||
@@ -612,6 +612,7 @@ function TemplatesPanel({
|
||||
const slots = tpl.build(page)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tpl.id}
|
||||
className="st-thumb"
|
||||
onClick={() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user