mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
Finish the NestJS migration — drop the legacy Express app
NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.
- Platform/transport routes extracted to nest/platform/platform.routes.ts
and mounted before app.init() — Nest's router answers an unmatched
request with a 404, so a route registered after init is never reached.
The SPA fallback is a NotFoundException filter and the catch-all uses a
RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
gallery's Immich/Synology proxy), addons (GET /api/addons) and the
cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
so upload rejections keep their 400/413 { error } body and non-ASCII
filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
app; removed the now-meaningless Express-vs-Nest parity tests and a few
orphaned client components.
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||
const [amount, setAmount] = useState('100')
|
||||
const [rate, setRate] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchRate = useCallback(async () => {
|
||||
if (from === to) { setRate(1); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||
const data = await resp.json()
|
||||
setRate(data.rates?.[to] || null)
|
||||
} catch { setRate(null) }
|
||||
finally { setLoading(false) }
|
||||
}, [from, to])
|
||||
|
||||
useEffect(() => { fetchRate() }, [fetchRate])
|
||||
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
||||
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
||||
|
||||
const swap = () => { setFrom(to); setTo(from) }
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—'
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
const result = rawResult
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
||||
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From / Swap / To */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
||||
<ArrowRightLeft size={13} />
|
||||
</button>
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
||||
</p>
|
||||
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TimezoneWidget from './TimezoneWidget'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TimezoneWidget', () => {
|
||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(document.body).toBeInTheDocument()
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||
render(<TimezoneWidget />)
|
||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
||||
expect(timeElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||
localStorage.clear()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Find and click Berlin in the popular zones list
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// Panel should be closed
|
||||
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Type label and timezone
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'My City')
|
||||
await user.type(tzInput, 'Europe/Paris')
|
||||
// Click Add
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText('My City')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'Invalid/Timezone')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Default zones include New York (America/New_York)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'America/New_York')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||
// Remove buttons for New York and Tokyo come after the Plus button
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||
await user.click(allButtons[1])
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'Singapore')
|
||||
await user.type(tzInput, 'Asia/Singapore')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'London', tz: 'Europe/London' },
|
||||
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
function getOffset(tz) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
||||
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
||||
const diff = (remote - local) / 3600000
|
||||
const sign = diff >= 0 ? '+' : ''
|
||||
return `${sign}${diff}h`
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
]
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
return () => clearInterval(i)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
}
|
||||
setShowAdd(false)
|
||||
}
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local time */}
|
||||
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
||||
</div>
|
||||
|
||||
{/* Zone list */}
|
||||
<div className="space-y-2">
|
||||
{zones.map(z => (
|
||||
<div key={z.tz} className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||
</div>
|
||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||
<button key={z.tz} onClick={() => addZone(z)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MobileTopHeader from './MobileTopHeader';
|
||||
|
||||
describe('MobileTopHeader', () => {
|
||||
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
|
||||
render(<MobileTopHeader title="Journeys" />);
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.textContent).toBe('Journeys');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
|
||||
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import MemoriesPanel from './MemoriesPanel';
|
||||
|
||||
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
|
||||
vi.mock('../../api/authUrl', () => ({
|
||||
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
|
||||
clearImageQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
startDate: '2025-03-01',
|
||||
endDate: '2025-03-10',
|
||||
};
|
||||
|
||||
// Reusable provider object to configure a connected Immich instance
|
||||
const immichAddon = {
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich/status' },
|
||||
};
|
||||
|
||||
// Handlers that simulate a connected provider with no photos/links
|
||||
const connectedHandlers = [
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () =>
|
||||
HttpResponse.json({ connected: true })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Seed a default logged-in user
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
|
||||
});
|
||||
|
||||
describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
|
||||
// Use a delayed response so loading stays true long enough to assert
|
||||
server.use(
|
||||
http.get('/api/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Spinner is rendered synchronously — loading state starts as true
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// "Photo provider not connected" — no providers, falls back to generic label
|
||||
await screen.findByText('Photo provider not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'abc',
|
||||
provider: 'immich',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Alice's username is rendered as an avatar tooltip in the gallery
|
||||
await screen.findByText('Alice');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
|
||||
server.use(...connectedHandlers);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider is connected so the gallery renders — but no photos → empty state
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
|
||||
let syncCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
|
||||
syncCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
const syncBtn = screen.getByTitle('Sync album');
|
||||
await userEvent.click(syncBtn);
|
||||
|
||||
await waitFor(() => expect(syncCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// The unlink button is only shown when link.user_id === currentUser.id
|
||||
const unlinkBtn = screen.getByTitle('Unlink album');
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Default sort is ascending ("Oldest first")
|
||||
const sortBtn = await screen.findByText('Oldest first');
|
||||
|
||||
await userEvent.click(sortBtn);
|
||||
|
||||
// After toggle, button label switches to "Newest first"
|
||||
expect(screen.getByText('Newest first')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the empty gallery to load
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
// Both the header button and gallery CTA say "Add photos" — click the first
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
|
||||
// Picker header is now visible
|
||||
await screen.findByText('Select photos from Immich');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click Cancel in the picker header
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
// Album picker header appears
|
||||
await screen.findByText('Select Immich Album');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Share-toggle button appears with correct title (not shared → "Share photos")
|
||||
await screen.findByTitle('Share photos');
|
||||
|
||||
// "Private" label is shown on unshared own photos
|
||||
expect(screen.getByText('Private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
|
||||
let putCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const shareBtn = await screen.findByTitle('Share photos');
|
||||
await userEvent.click(shareBtn);
|
||||
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the share/stop-sharing button to confirm the gallery has rendered
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// The remove button is the second action button in the hover overlay — no title, just an X icon
|
||||
// Get all buttons and click the one after the share toggle
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
|
||||
// The remove button immediately follows the share button in the DOM
|
||||
await userEvent.click(allBtns[shareIdx + 1]);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Month group header appears after photos load
|
||||
await screen.findByText(/March.*2025|2025.*March/);
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [
|
||||
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
await screen.findByText('Summer 2025');
|
||||
// Asset count is rendered next to the album name
|
||||
expect(screen.getByText(/42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
|
||||
const immich2Addon = {
|
||||
id: 'immich2',
|
||||
name: 'Immich2',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich2/status' },
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
|
||||
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
// With multiple providers the picker header uses the "multiple" translation
|
||||
await screen.findByText('Select Photos');
|
||||
|
||||
// Both provider name tabs are rendered inside the picker
|
||||
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Location dropdown shows "All locations" option when there are 2+ distinct cities
|
||||
await screen.findByText('All locations');
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
|
||||
let addPhotosCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
addPhotosCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
|
||||
// img has alt="" so findByRole('img') won't work — use findByAltText instead
|
||||
const thumbnail = await screen.findByAltText('');
|
||||
|
||||
// Click the thumbnail — bubbles up to the parent div's onClick to select it
|
||||
await userEvent.click(thumbnail);
|
||||
|
||||
// "1 selected" count appears and "Add 1 photos" button is active
|
||||
await screen.findByText(/1\s+selected/);
|
||||
await userEvent.click(screen.getByText('Add 1 photos'));
|
||||
|
||||
// Confirm share dialog appears
|
||||
await screen.findByText('Share with trip members?');
|
||||
|
||||
// Click the confirm "Share photos" button to execute
|
||||
await userEvent.click(screen.getByText('Share photos'));
|
||||
|
||||
await waitFor(() => expect(addPhotosCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
|
||||
let searchCount = 0;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () => {
|
||||
searchCount++;
|
||||
return HttpResponse.json({ assets: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click "All photos" — triggers a second loadPickerPhotos(false) call
|
||||
await userEvent.click(screen.getByText('All photos'));
|
||||
|
||||
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// "Trip dates" tab is absent when dates are not set
|
||||
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All photos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider name shown in the not-connected message when exactly 1 enabled provider
|
||||
await screen.findByText('MyApp not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'asset1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
|
||||
await userEvent.click(screen.getByText('Add photos'));
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// The asset already in the gallery shows the "Added" overlay in the picker
|
||||
await screen.findByText('Added');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const select = await screen.findByRole('combobox');
|
||||
|
||||
// Change filter to a specific city
|
||||
await userEvent.selectOptions(select, 'Paris');
|
||||
|
||||
expect(select).toHaveValue('Paris');
|
||||
});
|
||||
|
||||
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// Other user's username is shown in parentheses
|
||||
expect(screen.getByText('(Alice)')).toBeInTheDocument();
|
||||
|
||||
// Unlink button is NOT shown for another user's album link
|
||||
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
|
||||
let linkCalled = false;
|
||||
// Track whether POST has been made so the GET can return different data
|
||||
let albumLinked = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
linkCalled = true;
|
||||
albumLinked = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
// Return empty before POST, linked album after POST
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
if (!albumLinked) return HttpResponse.json({ links: [] });
|
||||
return HttpResponse.json({
|
||||
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
|
||||
});
|
||||
}),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
|
||||
HttpResponse.json({ ok: true })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Summer 2025');
|
||||
|
||||
// Click the album button to link it (album is not yet linked → button is enabled)
|
||||
await userEvent.click(screen.getByText('Summer 2025'));
|
||||
|
||||
await waitFor(() => expect(linkCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Select Immich Album');
|
||||
|
||||
// Click Cancel to dismiss without linking
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
size?: number
|
||||
title?: string
|
||||
className?: string
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
// Button that morphs between copy icon and check icon for 1.5s after click.
|
||||
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
onCopy?.()
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, [value, onCopy])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: copied ? '#22c55e' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Copy size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 0 : 1,
|
||||
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
|
||||
}} />
|
||||
<Check size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 1 : 0,
|
||||
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
|
||||
strokeWidth: 2.5,
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -1,36 +0,0 @@
|
||||
import React, { useState, type ImgHTMLAttributes } from 'react'
|
||||
|
||||
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
containerClassName?: string
|
||||
containerStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
|
||||
export function LoadingImage({
|
||||
containerClassName, containerStyle, className, style, onLoad, ...imgProps
|
||||
}: LoadingImageProps): React.ReactElement {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
return (
|
||||
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
|
||||
{!loaded && (
|
||||
<div
|
||||
className="trek-skeleton"
|
||||
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
{...imgProps}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
}}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingImage
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* usePendingMutations — returns the set of entity IDs that have a pending
|
||||
* or syncing mutation for a given trip.
|
||||
*
|
||||
* Components use this to render a clock/pending indicator on list rows.
|
||||
* Polls Dexie every 2 s so the indicator clears automatically once synced.
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
|
||||
export function usePendingMutations(tripId: number): Set<number> {
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
const pending = await mutationQueue.pending(tripId)
|
||||
if (cancelled) return
|
||||
|
||||
const ids = new Set<number>()
|
||||
for (const m of pending) {
|
||||
// Extract entity id from the mutation URL (last numeric segment)
|
||||
const match = m.url.match(/\/(\d+)$/)
|
||||
if (match) ids.add(Number(match[1]))
|
||||
// Also include tempId for offline-created items
|
||||
if (m.tempId !== undefined) ids.add(m.tempId)
|
||||
}
|
||||
setPendingIds(ids)
|
||||
}
|
||||
|
||||
refresh()
|
||||
const timer = setInterval(refresh, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
return pendingIds
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { swapItems } from '../../../src/utils/reorder';
|
||||
|
||||
// FE-UTIL-020 onwards
|
||||
|
||||
const items = [
|
||||
{ id: 10 },
|
||||
{ id: 20 },
|
||||
{ id: 30 },
|
||||
{ id: 40 },
|
||||
];
|
||||
|
||||
describe('swapItems', () => {
|
||||
it('FE-UTIL-020: swaps item up with its predecessor', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result).toEqual([20, 10, 30, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-021: swaps item down with its successor', () => {
|
||||
const result = swapItems(items, 1, 'down');
|
||||
expect(result).toEqual([10, 30, 20, 40]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-022: returns null when moving first item up (out of bounds)', () => {
|
||||
expect(swapItems(items, 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-023: returns null when moving last item down (out of bounds)', () => {
|
||||
expect(swapItems(items, items.length - 1, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-024: swaps first and second items when moving index 1 up', () => {
|
||||
const result = swapItems(items, 1, 'up');
|
||||
expect(result![0]).toBe(20);
|
||||
expect(result![1]).toBe(10);
|
||||
});
|
||||
|
||||
it('FE-UTIL-025: returns an array of IDs (not objects)', () => {
|
||||
const result = swapItems(items, 0, 'down');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(typeof result![0]).toBe('number');
|
||||
});
|
||||
|
||||
it('FE-UTIL-026: does not mutate the original array', () => {
|
||||
const original = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
const snapshot = original.map((o) => o.id);
|
||||
swapItems(original, 0, 'down');
|
||||
expect(original.map((o) => o.id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('FE-UTIL-027: returns null for a single-element array moving down', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'down')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-028: returns null for a single-element array moving up', () => {
|
||||
expect(swapItems([{ id: 5 }], 0, 'up')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-UTIL-029: swaps last two items when moving second-to-last down', () => {
|
||||
const result = swapItems(items, items.length - 2, 'down');
|
||||
expect(result).toEqual([10, 20, 40, 30]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user