diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cce0ef3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Normalize line endings to LF on commit +* text=auto eol=lf + +# Explicitly enforce LF for source files +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.py text eol=lf +*.sh text eol=lf + +# Binary files — no line ending conversion +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.pdf binary +*.zip binary diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb16112..70eea81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,11 +4,6 @@ permissions: contents: read on: - push: - branches: [main, dev] - paths: - - 'server/**' - - '.github/workflows/test.yml' pull_request: branches: [main, dev] paths: diff --git a/.gitignore b/.gitignore index fbf5e80..f58a53e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ coverage *.tsbuildinfo *.tgz -.scannerwork \ No newline at end of file +.scannerwork +test-data \ No newline at end of file diff --git a/client/public/icons/trek-loading-dark.gif b/client/public/icons/trek-loading-dark.gif new file mode 100644 index 0000000..1397a63 Binary files /dev/null and b/client/public/icons/trek-loading-dark.gif differ diff --git a/client/public/icons/trek-loading-light.gif b/client/public/icons/trek-loading-light.gif new file mode 100644 index 0000000..8c6ea31 Binary files /dev/null and b/client/public/icons/trek-loading-light.gif differ diff --git a/client/src/App.tsx b/client/src/App.tsx index d77755a..621201e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -43,7 +43,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps } if (!isAuthenticated) { - return + const redirectParam = encodeURIComponent(location.pathname + location.search) + return } if ( diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 9cdc541..cebd2f9 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,4 +1,4 @@ -export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { +export async function getAuthUrl(url: string, purpose: 'download'): Promise { if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81a28b6..eb3a236 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -27,7 +27,8 @@ apiClient.interceptors.response.use( (error) => { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) { - window.location.href = '/login' + const currentPath = window.location.pathname + window.location.search + window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) } } if ( @@ -130,12 +131,24 @@ export const packingApi = { getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), + saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), + setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data), listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data), createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data), updateBag: (tripId: number | string, bagId: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data), deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } +export const todoApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), + delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data), + getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), +} + export const tagsApi = { list: () => apiClient.get('/tags').then(r => r.data), create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), @@ -187,6 +200,8 @@ export const adminApi = { rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), sendTestNotification: (data: Record) => apiClient.post('/admin/dev/test-notification', data).then(r => r.data), + getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data), + updateNotificationPreferences: (prefs: Record>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), } export const addonsApi = { @@ -316,9 +331,9 @@ export const shareApi = { export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), - updatePreferences: (prefs: Record) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), + updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), - testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data), + testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data), } export const inAppNotificationsApi = { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 3050258..b45f9f0 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -15,7 +15,17 @@ interface Addon { name: string description: string icon: string + type: string enabled: boolean + config?: Record +} + +interface ProviderOption { + key: string + label: string + description: string + enabled: boolean + toggle: () => Promise } interface AddonIconProps { @@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) + const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const handleToggle = async (addon) => { + const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) @@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } + const isPhotoProviderAddon = (addon: Addon) => { + return addon.type === 'photo_provider' + } + + const isPhotosAddon = (addon: Addon) => { + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() + return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + } + + const handleTogglePhotoProvider = async (providerAddon: Addon) => { + const enableProvider = !providerAddon.enabled + const prev = addons + + setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) + + try { + await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) + refreshGlobalAddons() + toast.success(t('admin.addons.toast.updated')) + } catch { + setAddons(prev) + toast.error(t('admin.addons.toast.error')) + } + } + const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const photoProviderAddons = addons.filter(isPhotoProviderAddon) const integrationAddons = addons.filter(a => a.type === 'integration') + const photosAddon = tripAddons.find(isPhotosAddon) + const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleTogglePhotoProvider(provider), + })) + const photosDerivedEnabled = providerOptions.some(p => p.enabled) if (loading) { return ( @@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {tripAddons.map(addon => (
- + + {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } interface AddonRowProps { addon: Addon - onToggle: (addonId: string) => void + onToggle: (addon: Addon) => void t: (key: string) => string + statusOverride?: boolean + hideToggle?: boolean } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { @@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string } } -function AddonRow({ addon, onToggle, t }: AddonRowProps) { +function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { const isComingSoon = false const label = getAddonLabel(t, addon) + const displayName = nameOverride || label.name + const displayDescription = descriptionOverride || label.description + const enabledState = statusOverride ?? addon.enabled return (
{/* Icon */} @@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Info */}
- {label.name} + {displayName} {isComingSoon && ( Coming Soon @@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
-

{label.description}

+

{displayDescription}

{/* Toggle */}
- - {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} - + {!hideToggle && ( + + )}
) diff --git a/client/src/components/Admin/DevNotificationsPanel.tsx b/client/src/components/Admin/DevNotificationsPanel.tsx index 31572ee..8221b51 100644 --- a/client/src/components/Admin/DevNotificationsPanel.tsx +++ b/client/src/components/Admin/DevNotificationsPanel.tsx @@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react' import { adminApi, tripsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' -import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react' +import { + Bell, Zap, ArrowRight, CheckCircle, Navigation, User, + Calendar, Clock, Image, MessageSquare, Tag, UserPlus, + Download, MapPin, +} from 'lucide-react' interface Trip { id: number @@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement { }).catch(() => {}) }, []) - const send = async (label: string, payload: Record) => { + const fire = async (label: string, payload: Record) => { setSending(label) try { await adminApi.sendTestNotification(payload) @@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement { } } - const buttons = [ - { - label: 'Simple → Me', - icon: Bell, - color: '#6366f1', - payload: { - type: 'simple', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.title', - title_params: { actor: user?.username || 'Admin' }, - text_key: 'notifications.test.text', - text_params: {}, - }, - }, - { - label: 'Boolean → Me', - icon: CheckCircle, - color: '#10b981', - payload: { - type: 'boolean', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.booleanTitle', - title_params: { actor: user?.username || 'Admin' }, - text_key: 'notifications.test.booleanText', - text_params: {}, - positive_text_key: 'notifications.test.accept', - negative_text_key: 'notifications.test.decline', - positive_callback: { action: 'test_approve', payload: {} }, - negative_callback: { action: 'test_deny', payload: {} }, - }, - }, - { - label: 'Navigate → Me', - icon: Navigation, - color: '#f59e0b', - payload: { - type: 'navigate', - scope: 'user', - target: user?.id, - title_key: 'notifications.test.navigateTitle', - title_params: {}, - text_key: 'notifications.test.navigateText', - text_params: {}, - navigate_text_key: 'notifications.test.goThere', - navigate_target: '/dashboard', - }, - }, - { - label: 'Simple → Admins', - icon: Zap, - color: '#ef4444', - payload: { - type: 'simple', - scope: 'admin', - target: 0, - title_key: 'notifications.test.adminTitle', - title_params: {}, - text_key: 'notifications.test.adminText', - text_params: { actor: user?.username || 'Admin' }, - }, - }, - ] + const selectedTrip = trips.find(t => t.id === selectedTripId) + const selectedUser = users.find(u => u.id === selectedUserId) + const username = user?.username || 'Admin' + const tripTitle = selectedTrip?.title || 'Test Trip' + + // ── Helpers ────────────────────────────────────────────────────────────── + + const Btn = ({ + id, label, sub, icon: Icon, color, onClick, + }: { + id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void + }) => ( + + ) + + const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ) + + const TripSelector = () => ( + + ) + + const UserSelector = () => ( + + ) return ( -
-
+
+
DEV ONLY
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
-

- Send test notifications to yourself, all admins, or trip members. These use test i18n keys. -

- - {/* Quick-fire buttons */} + {/* ── Type Testing ─────────────────────────────────────────────────── */}
-

Quick Send

+ Type Testing +

+ Test how each in-app notification type renders, sent to yourself. +

- {buttons.map(btn => { - const Icon = btn.icon - return ( - - ) - })} + fire('simple-me', { + event: 'test_simple', + scope: 'user', + targetId: user?.id, + params: {}, + })} + /> + fire('boolean-me', { + event: 'test_boolean', + scope: 'user', + targetId: user?.id, + params: {}, + inApp: { + type: 'boolean', + positiveCallback: { action: 'test_approve', payload: {} }, + negativeCallback: { action: 'test_deny', payload: {} }, + }, + })} + /> + fire('navigate-me', { + event: 'test_navigate', + scope: 'user', + targetId: user?.id, + params: {}, + })} + /> + fire('simple-admins', { + event: 'test_simple', + scope: 'admin', + targetId: 0, + params: {}, + })} + />
- {/* Trip-scoped notifications */} + {/* ── Trip-Scoped Events ───────────────────────────────────────────── */} {trips.length > 0 && (
-

Trip-Scoped

-
- -
+ Trip-Scoped Events +

+ Fires each trip event to all members of the selected trip (excluding yourself). +

+
- - + /> + selectedTripId && fire('photos_shared', { + event: 'photos_shared', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) }, + })} + /> + selectedTripId && fire('collab_message', { + event: 'collab_message', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) }, + })} + /> + selectedTripId && fire('packing_tagged', { + event: 'packing_tagged', + scope: 'trip', + targetId: selectedTripId, + params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) }, + })} + />
)} - {/* User-scoped notifications */} + {/* ── User-Scoped Events ───────────────────────────────────────────── */} {users.length > 0 && (
-

User-Scoped

-
- -
+ User-Scoped Events +

+ Fires each user event to the selected recipient. +

+
- - - + />
)} + + {/* ── Admin-Scoped Events ──────────────────────────────────────────── */} +
+ Admin-Scoped Events +

+ Fires to all admin users. +

+
+ fire('version_available', { + event: 'version_available', + scope: 'admin', + targetId: 0, + params: { version: '9.9.9-test' }, + })} + /> +
+
) } diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 96a3b28..ad76c2a 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react' -import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react' +import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react' import { getLocaleForLanguage, useTranslation } from '../../i18n' import apiClient from '../../api/client' -const REPO = 'mauriceboe/NOMAD' +const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 export default function GitHubPanel() { @@ -176,6 +176,63 @@ export default function GitHubPanel() {
+
+ { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} + > +
+ +
+
+
{t('settings.about.reportBug')}
+
{t('settings.about.reportBugHint')}
+
+ +
+ { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} + > +
+ +
+
+
{t('settings.about.featureRequest')}
+
{t('settings.about.featureRequestHint')}
+
+ +
+ { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }} + onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} + > +
+ +
+
+
Wiki
+
{t('settings.about.wikiHint')}
+
+ +
+
+ {/* Loading / Error / Releases */} {loading ? (
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index a0cbd3d..e1f117b 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -633,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> - handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> {/* Mobile: larger chips under name since Persons column is hidden */} {hasMultipleMembers && (
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 933ca84..3f8aef7 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import DOM from 'react-dom' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' +import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' import { collabApi } from '../../api/client' import { getAuthUrl } from '../../api/authUrl' import { useCanDo } from '../../store/permissionsStore' @@ -100,6 +100,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const [authUrl, setAuthUrl] = useState('') const rawUrl = file?.url || '' useEffect(() => { + setAuthUrl('') if (!rawUrl) return getAuthUrl(rawUrl, 'download').then(setAuthUrl) }, [rawUrl]) @@ -119,7 +120,10 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { {isImage ? ( /* Image lightbox — floating controls */
e.stopPropagation()}> - {file.original_name} + {authUrl + ? {file.original_name} + : + }
{file.original_name}
@@ -487,7 +491,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca const isImage = a.mime_type?.startsWith('image/') return (
- {isImage && } + {isImage && } {(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
{viewingNote.content || ''} + {(viewingNote.attachments || []).length > 0 && ( +
+
{t('files.title')}
+
+ {(viewingNote.attachments || []).map(a => { + const isImage = a.mime_type?.startsWith('image/') + const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?' + return ( +
+ {isImage ? ( + setPreviewFile(a)} + 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' }} /> + ) : ( +
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', + }} + 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' }}> + {ext} +
+ )} + {a.original_name} +
+ ) + })} +
+
+ )}
, diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 3dc6044..dbaefa7 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,7 +1,7 @@ import ReactDOM from 'react-dom' import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' -import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react' +import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { filesApi } from '../../api/client' @@ -37,49 +37,121 @@ function formatDateWithLocale(dateStr, locale) { } catch { return '' } } -// Image lightbox +// Image lightbox with gallery navigation interface ImageLightboxProps { - file: TripFile & { url: string } + files: (TripFile & { url: string })[] + initialIndex: number onClose: () => void } -function ImageLightbox({ file, onClose }: ImageLightboxProps) { +function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) { const { t } = useTranslation() + const [index, setIndex] = useState(initialIndex) const [imgSrc, setImgSrc] = useState('') + const [touchStart, setTouchStart] = useState(null) + const file = files[index] + useEffect(() => { - getAuthUrl(file.url, 'download').then(setImgSrc) - }, [file.url]) + setImgSrc('') + if (file) getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file?.url]) + + const goPrev = () => setIndex(i => Math.max(0, i - 1)) + const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1)) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowLeft') goPrev() + if (e.key === 'ArrowRight') goNext() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + if (!file) return null + + const hasPrev = index > 0 + const hasNext = index < files.length - 1 + const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? ( + + ) : null + return (
setTouchStart(e.touches[0].clientX)} + onTouchEnd={e => { + if (touchStart === null) return + const diff = e.changedTouches[0].clientX - touchStart + if (diff > 60) goPrev() + else if (diff < -60) goNext() + setTouchStart(null) + }} > -
e.stopPropagation()}> - {file.original_name} -
- {file.original_name} -
- - -
+ {/* Header */} +
e.stopPropagation()}> + + {file.original_name} + {index + 1} / {files.length} + +
+ +
+ + {/* Main image + nav */} +
{ if (e.target === e.currentTarget) onClose() }}> + {navBtn('left', goPrev, hasPrev)} + {imgSrc && {file.original_name} e.stopPropagation()} />} + {navBtn('right', goNext, hasNext)} +
+ + {/* Thumbnail strip */} + {files.length > 1 && ( +
e.stopPropagation()}> + {files.map((f, i) => ( + setIndex(i)} /> + ))} +
+ )}
) } +function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) { + const [src, setSrc] = useState('') + useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url]) + return ( + + ) +} + // Authenticated image — fetches a short-lived download token and renders the image function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) { const [authSrc, setAuthSrc] = useState('') @@ -169,7 +241,7 @@ interface FileManagerProps { export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) const [filterType, setFilterType] = useState('all') - const [lightboxFile, setLightboxFile] = useState(null) + const [lightboxIndex, setLightboxIndex] = useState(null) const [showTrash, setShowTrash] = useState(false) const [trashFiles, setTrashFiles] = useState([]) const [loadingTrash, setLoadingTrash] = useState(false) @@ -324,9 +396,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } } + const imageFiles = filteredFiles.filter(f => isImage(f.mime_type)) + const openFile = (file) => { if (isImage(file.mime_type)) { - setLightboxFile(file) + const idx = imageFiles.findIndex(f => f.id === file.id) + setLightboxIndex(idx >= 0 ? idx : 0) } else { setPreviewFile(file) } @@ -453,7 +528,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, return (
{/* Lightbox */} - {lightboxFile && setLightboxFile(null)} />} + {lightboxIndex !== null && setLightboxIndex(null)} />} {/* Assign modal */} {assignFileId && ReactDOM.createPortal( diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx index fff46be..1bbc53c 100644 --- a/client/src/components/Layout/DemoBanner.tsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -118,6 +118,70 @@ const texts: Record = { selfHostLink: 'alójalo tú mismo', close: 'Entendido', }, + zh: { + titleBefore: '欢迎来到 ', + titleAfter: '', + title: '欢迎来到 TREK 演示版', + description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。', + resetIn: '下次重置将在', + minutes: '分钟后', + uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。', + fullVersionTitle: '完整版本还包括:', + features: [ + '文件上传(照片、文档、封面)', + 'API 密钥管理(Google Maps、天气)', + '用户和权限管理', + '自动备份', + '附加组件管理(启用/禁用)', + 'OIDC / SSO 单点登录', + ], + addonsTitle: '模块化附加组件(完整版本可禁用)', + addons: [ + ['Vacay', '带日历、节假日和用户融合的假期规划器'], + ['Atlas', '带已访问国家和旅行统计的世界地图'], + ['Packing', '按旅行管理清单'], + ['Budget', '支持分摊的费用追踪'], + ['Documents', '将文件附加到旅行'], + ['Widgets', '货币换算和时区工具'], + ], + whatIs: '什么是 TREK?', + whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。', + selfHost: '开源项目 - ', + selfHostLink: '自行部署', + close: '知道了', + }, + 'zh-TW': { + titleBefore: '歡迎來到 ', + titleAfter: '', + title: '歡迎來到 TREK 展示版', + description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。', + resetIn: '下次重設將在', + minutes: '分鐘後', + uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。', + fullVersionTitle: '完整版本還包含:', + features: [ + '檔案上傳(照片、文件、封面)', + 'API 金鑰管理(Google Maps、天氣)', + '使用者與權限管理', + '自動備份', + '附加元件管理(啟用/停用)', + 'OIDC / SSO 單一登入', + ], + addonsTitle: '模組化附加元件(完整版本可停用)', + addons: [ + ['Vacay', '具備日曆、假日與使用者融合的假期規劃器'], + ['Atlas', '顯示已造訪國家與旅行統計的世界地圖'], + ['Packing', '依行程管理的檢查清單'], + ['Budget', '支援分攤的費用追蹤'], + ['Documents', '將檔案附加到行程'], + ['Widgets', '貨幣換算與時區工具'], + ], + whatIs: 'TREK 是什麼?', + whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。', + selfHost: '開源專案 - ', + selfHostLink: '自行架設', + close: '知道了', + }, ar: { titleBefore: 'مرحبًا بك في ', titleAfter: '', diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx index fcf14cb..0b22038 100644 --- a/client/src/components/Layout/InAppNotificationBell.tsx +++ b/client/src/components/Layout/InAppNotificationBell.tsx @@ -96,7 +96,7 @@ export default function InAppNotificationBell(): React.ReactElement { {t('notifications.title')} {unreadCount > 0 && ( + style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}> {unreadCount} )} @@ -133,7 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
{isLoading && notifications.length === 0 ? (
-
+
) : notifications.length === 0 ? (
@@ -154,7 +154,7 @@ export default function InAppNotificationBell(): React.ReactElement { className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)', - color: '#6366f1', + color: 'var(--text-primary)', background: 'transparent', }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 1d92876..e4e1dc9 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -133,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {tripTitle && ( <> / - + {tripTitle} @@ -155,17 +155,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} - {/* Dark mode toggle (light ↔ dark, overrides auto) */} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} - {/* Notification bell */} - {user && } + {/* Notification bell — only in trip view on mobile, everywhere on desktop */} + {user && tripId && } + {user && !tripId && } {/* User menu */} {user && ( diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index a3ff1bb..32d1ec4 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -161,12 +161,13 @@ function MapController({ center, zoom }: MapControllerProps) { // Fit bounds when places change (fitKey triggers re-fit) interface BoundsControllerProps { + hasDayDetail?: boolean places: Place[] fitKey: number paddingOpts: Record } -function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) { +function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) @@ -176,9 +177,14 @@ function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps if (places.length === 0) return try { const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) - if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) + if (bounds.isValid()) { + map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) + if (hasDayDetail) { + setTimeout(() => map.panBy([0, 150], { animate: true }), 300) + } + } } catch {} - }, [fitKey, places, paddingOpts, map]) + }, [fitKey, places, paddingOpts, map, hasDayDetail]) return null } @@ -377,17 +383,18 @@ export const MapView = memo(function MapView({ leftWidth = 0, rightWidth = 0, hasInspector = false, + hasDayDetail = false, }) { - // Dynamic padding: account for sidebars + bottom inspector + // Dynamic padding: account for sidebars + bottom inspector + day detail panel const paddingOpts = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { padding: [40, 20] } const top = 60 - const bottom = hasInspector ? 320 : 60 + const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60 const left = leftWidth + 40 const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } - }, [leftWidth, rightWidth, hasInspector]) + }, [leftWidth, rightWidth, hasInspector, hasDayDetail]) // photoUrls: only base64 thumbs for smooth map zoom const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) @@ -509,7 +516,7 @@ export const MapView = memo(function MapView({ /> - 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} /> + 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} /> diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 13a8bab..a466e26 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,16 +1,23 @@ import { useState, useEffect, useCallback } from 'react' -import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' -import apiClient from '../../api/client' +import apiClient, { addonsApi } from '../../api/client' +import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' -import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' +import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' import { useToast } from '../shared/Toast' -function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { +interface PhotoProvider { + id: string + name: string + icon?: string + config?: Record +} + +function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { let revoke = '' - fetchImageAsBlob(baseUrl).then(blobUrl => { + fetchImageAsBlob('/api' + baseUrl).then(blobUrl => { revoke = blobUrl setSrc(blobUrl) }) @@ -19,18 +26,22 @@ function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React return src ? : null } + // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { - immich_asset_id: string + asset_id: string + provider: string user_id: number username: string shared: number added_at: string + city?: string | null } -interface ImmichAsset { +interface Asset { id: string + provider: string takenAt: string city: string | null country: string | null @@ -50,6 +61,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) + const [enabledProviders, setEnabledProviders] = useState([]) + const [availableProviders, setAvailableProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') const [loading, setLoading] = useState(true) // Trip photos (saved selections) @@ -57,7 +71,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // Photo picker const [showPicker, setShowPicker] = useState(false) - const [pickerPhotos, setPickerPhotos] = useState([]) + const [pickerPhotos, setPickerPhotos] = useState([]) const [pickerLoading, setPickerLoading] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -72,49 +86,102 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [showAlbumPicker, setShowAlbumPicker] = useState(false) const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) - const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) + const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) + + //helpers for building urls + const ADDON_PREFIX = "/integrations/memories" + + function buildUnifiedUrl(endpoint: string, lastParam?:string,): string { + return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`; + } + + function buildProviderUrl(provider: string, endpoint: string, item?: string): string { + if (endpoint === 'album-link-sync') { + endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync` + } + return `${ADDON_PREFIX}/${provider}/${endpoint}`; + } + + function buildProviderAssetUrl(photo: TripPhoto, what: string): string { + return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}` + } + + function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string { + const photo: TripPhoto = { + asset_id: asset.id, + provider: asset.provider, + user_id: userId, + username: '', + shared: 0, + added_at: null + } + return buildProviderAssetUrl(photo, what) + } + + const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + const res = await apiClient.get(buildUnifiedUrl('album-links')) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } - const openAlbumPicker = async () => { - setShowAlbumPicker(true) + const loadAlbums = async (provider: string = selectedProvider) => { + if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get('/integrations/immich/albums') + const res = await apiClient.get(buildProviderUrl(provider, 'albums')) setAlbums(res.data.albums || []) - } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } - finally { setAlbumsLoading(false) } + } catch { + setAlbums([]) + toast.error(t('memories.error.loadAlbums')) + } finally { + setAlbumsLoading(false) + } + } + + const openAlbumPicker = async () => { + setShowAlbumPicker(true) + await loadAlbums(selectedProvider) } const linkAlbum = async (albumId: string, albumName: string) => { + if (!selectedProvider) { + toast.error(t('memories.error.linkAlbum')) + return + } + try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(buildUnifiedUrl('album-links'), { + album_id: albumId, + album_name: albumName, + provider: selectedProvider, + }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) - const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) + const linksRes = await apiClient.get(buildUnifiedUrl('album-links')) + const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) - loadAlbumLinks() + await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString())) + await loadAlbumLinks() + await loadPhotos() } catch { toast.error(t('memories.error.unlinkAlbum')) } } - const syncAlbum = async (linkId: number) => { + const syncAlbum = async (linkId: number, provider?: string) => { + const targetProvider = provider || selectedProvider + if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString())) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -127,6 +194,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') + const [showMobileInfo, setShowMobileInfo] = useState(false) + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) // ── Init ────────────────────────────────────────────────────────────────── @@ -143,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) + const photosRes = await apiClient.get(buildUnifiedUrl('photos')) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -153,9 +228,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadInitial = async () => { setLoading(true) try { - const statusRes = await apiClient.get('/integrations/immich/status') - setConnected(statusRes.data.connected) + const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) + const enabledAddons = addonsRes?.addons || [] + const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) + + setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config }))) + + // Test connection status for each enabled provider + const statusResults = await Promise.all( + photoProviders.map(async (provider: any) => { + const statusUrl = (provider.config as Record)?.status_get as string + if (!statusUrl) return { provider, connected: false } + try { + const res = await apiClient.get(statusUrl) + return { provider, connected: !!res.data?.connected } + } catch { + return { provider, connected: false } + } + }) + ) + + const connectedProviders = statusResults + .filter(r => r.connected) + .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) + + setAvailableProviders(connectedProviders) + setConnected(connectedProviders.length > 0) + if (connectedProviders.length > 0 && !selectedProvider) { + setSelectedProvider(connectedProviders[0].id) + } } catch { + setAvailableProviders([]) setConnected(false) } await loadPhotos() @@ -175,14 +278,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await loadPickerPhotos(!!(startDate && endDate)) } + useEffect(() => { + if (showPicker) { + loadPickerPhotos(pickerDateFilter) + } + }, [selectedProvider]) + + useEffect(() => { + loadAlbumLinks() + }, [tripId]) + + useEffect(() => { + if (showAlbumPicker) { + loadAlbums(selectedProvider) + } + }, [showAlbumPicker, selectedProvider, tripId]) + const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post('/integrations/immich/search', { + const provider = availableProviders.find(p => p.id === selectedProvider) + if (!provider) { + setPickerPhotos([]) + return + } + const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) - setPickerPhotos(res.data.assets || []) + setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id }))) } catch { setPickerPhotos([]) toast.error(t('memories.error.loadPhotos')) @@ -208,8 +332,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { - asset_ids: [...selectedIds], + const groupedByProvider = new Map() + for (const key of selectedIds) { + const [provider, assetId] = key.split('::') + if (!provider || !assetId) continue + const list = groupedByProvider.get(provider) || [] + list.push(assetId) + groupedByProvider.set(provider, list) + } + + await apiClient.post(buildUnifiedUrl('photos'), { + selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), shared: true, }) setShowPicker(false) @@ -220,28 +353,38 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Remove photo ────────────────────────────────────────────────────────── - const removePhoto = async (assetId: string) => { + const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) - setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) + await apiClient.delete(buildUnifiedUrl('photos'), { + data: { + asset_id: photo.asset_id, + provider: photo.provider, + }, + }) + setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id))) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── - const toggleSharing = async (assetId: string, shared: boolean) => { + const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) + await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { + shared, + asset_id: photo.asset_id, + provider: photo.provider, + }) setTripPhotos(prev => prev.map(p => - p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p + p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── - const thumbnailBaseUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` + + + const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -281,10 +424,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

- {t('memories.notConnected')} + {t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}

- {t('memories.notConnectedHint')} + {enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}

) @@ -292,22 +435,53 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── + const ProviderTabs = () => { + if (availableProviders.length < 2) return null + return ( +
+ {availableProviders.map(provider => ( + + ))} +
+ ) + } + // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { - const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) + const linkedIds = new Set(albumLinks.map(l => l.album_id)) return (

- {t('memories.selectAlbum')} + {availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

+
{albumsLoading ? ( @@ -359,7 +533,11 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── if (showPicker) { - const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id)) + const alreadyAdded = new Set( + tripPhotos + .filter(p => p.user_id === currentUser?.id) + .map(p => makePickerKey(p.provider, p.asset_id)) + ) return ( <> @@ -368,7 +546,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

- {t('memories.selectPhotos')} + {availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}

+
+ +
{/* Filter tabs */}
{startDate && endDate && ( @@ -429,10 +610,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{t('memories.noPhotos')}

+ { + pickerDateFilter && ( +

+ {t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })} +

+ ) + }
) : (() => { // Group photos by month - const byMonth: Record = {} + const byMonth: Record = {} for (const asset of pickerPhotos) { const d = asset.takenAt ? new Date(asset.takenAt) : null const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' @@ -450,11 +638,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{byMonth[month].map(asset => { - const isSelected = selectedIds.has(asset.id) - const isAlready = alreadyAdded.has(asset.id) + const pickerKey = makePickerKey(asset.provider, asset.id) + const isSelected = selectedIds.has(pickerKey) + const isAlready = alreadyAdded.has(pickerKey) return ( -
!isAlready && togglePickerSelect(asset.id)} +
!isAlready && togglePickerSelect(pickerKey)} style={{ position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', cursor: isAlready ? 'default' : 'pointer', @@ -462,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa outline: isSelected ? '3px solid var(--text-primary)' : 'none', outlineOffset: -3, }}> - {isSelected && (
{link.album_name} {link.username !== currentUser?.username && ({link.username})} - @@ -616,12 +805,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {allVisible.length === 0 ? (
-

+

{t('memories.noPhotos')}

-

- {t('memories.noPhotosHint')} -

- -
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> - + {lightboxId && lightboxUserId && (() => { + const closeLightbox = () => { + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) + setLightboxOriginalSrc('') + setLightboxId(null) + setLightboxUserId(null) + setShowMobileInfo(false) + } - {/* Info panel — liquid glass */} - {lightboxInfo && ( -
- {/* Date */} - {lightboxInfo.takenAt && ( + const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId) + const hasPrev = currentIdx > 0 + const hasNext = currentIdx < allVisible.length - 1 + const navigateTo = (idx: number) => { + const photo = allVisible[idx] + if (!photo) return + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) + setLightboxOriginalSrc('') + setLightboxId(photo.asset_id) + setLightboxUserId(photo.user_id) + setLightboxInfo(null) + fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) + } + + const exifContent = lightboxInfo ? ( + <> + {lightboxInfo.takenAt && ( +
+
Date
+
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
+
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+
+ )} + {(lightboxInfo.city || lightboxInfo.country) && ( +
+
+ Location +
+
+ {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} +
+
+ )} + {lightboxInfo.camera && ( +
+
Camera
+
{lightboxInfo.camera}
+ {lightboxInfo.lens &&
{lightboxInfo.lens}
} +
+ )} + {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( +
+ {lightboxInfo.focalLength && (
-
Date
-
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
-
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+
Focal
+
{lightboxInfo.focalLength}
)} - - {/* Location */} - {(lightboxInfo.city || lightboxInfo.country) && ( + {lightboxInfo.aperture && (
-
- Location -
-
- {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} -
+
Aperture
+
{lightboxInfo.aperture}
)} - - {/* Camera */} - {lightboxInfo.camera && ( + {lightboxInfo.shutter && (
-
Camera
-
{lightboxInfo.camera}
- {lightboxInfo.lens &&
{lightboxInfo.lens}
} +
Shutter
+
{lightboxInfo.shutter}
)} - - {/* Settings */} - {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( -
- {lightboxInfo.focalLength && ( -
-
Focal
-
{lightboxInfo.focalLength}
-
- )} - {lightboxInfo.aperture && ( -
-
Aperture
-
{lightboxInfo.aperture}
-
- )} - {lightboxInfo.shutter && ( -
-
Shutter
-
{lightboxInfo.shutter}
-
- )} - {lightboxInfo.iso && ( -
-
ISO
-
{lightboxInfo.iso}
-
- )} -
- )} - - {/* Resolution & File */} - {(lightboxInfo.width || lightboxInfo.fileName) && ( -
- {lightboxInfo.width && lightboxInfo.height && ( -
{lightboxInfo.width} × {lightboxInfo.height}
- )} - {lightboxInfo.fileSize && ( -
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
- )} + {lightboxInfo.iso && ( +
+
ISO
+
{lightboxInfo.iso}
)}
)} + {(lightboxInfo.width || lightboxInfo.fileName) && ( +
+ {lightboxInfo.width && lightboxInfo.height && ( +
{lightboxInfo.width} × {lightboxInfo.height}
+ )} + {lightboxInfo.fileSize && ( +
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
+ )} +
+ )} + + ) : null - {lightboxInfoLoading && ( -
-
+ return ( +
{ if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }} + tabIndex={0} ref={el => el?.focus()} + onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX} + onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }} + style={{ + position: 'absolute', inset: 0, zIndex: 100, outline: 'none', + background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', + }}> + {/* Close button */} + + + {/* Counter */} + {allVisible.length > 1 && ( +
+ {currentIdx + 1} / {allVisible.length} +
+ )} + + {/* Prev/Next buttons */} + {hasPrev && ( + + )} + {hasNext && ( + + )} + + {/* Mobile info toggle button */} + {isMobile && (lightboxInfo || lightboxInfoLoading) && ( + + )} + +
{ if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> + e.stopPropagation()} + style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }} + /> + + {/* Desktop info panel — liquid glass */} + {!isMobile && lightboxInfo && ( +
+ {exifContent} +
+ )} + + {!isMobile && lightboxInfoLoading && ( +
+
+
+ )} +
+ + {/* Mobile bottom sheet */} + {isMobile && showMobileInfo && lightboxInfo && ( +
e.stopPropagation()} style={{ + position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5, + maxHeight: '60vh', overflowY: 'auto', + borderRadius: '16px 16px 0 0', padding: 18, + background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', + border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none', + color: 'white', display: 'flex', flexDirection: 'column', gap: 14, + }}> + {exifContent}
)}
-
- )} + ) + })()}
) } diff --git a/client/src/components/Notifications/InAppNotificationItem.tsx b/client/src/components/Notifications/InAppNotificationItem.tsx index a791fe7..f0ef4fa 100644 --- a/client/src/components/Notifications/InAppNotificationItem.tsx +++ b/client/src/components/Notifications/InAppNotificationItem.tsx @@ -59,10 +59,6 @@ export default function InAppNotificationItem({ notification, onClose }: Notific borderBottom: '1px solid var(--border-secondary)', }} > - {/* Unread dot */} - {!notification.is_read && ( -
- )}
{/* Sender avatar */} @@ -102,7 +98,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific title={t('notifications.markRead')} className="p-1 rounded transition-colors" style={{ color: 'var(--text-faint)' }} - onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }} + onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }} > @@ -134,7 +130,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors" style={{ background: notification.response === 'positive' - ? '#6366f1' + ? 'var(--text-primary)' : notification.response === 'negative' ? (dark ? '#27272a' : '#f1f5f9') : (dark ? '#27272a' : '#f1f5f9'), diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 2a4c03a..2ef5fdc 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -1,22 +1,33 @@ // Trip PDF via browser print window import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' -import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react' -import { mapsApi } from '../../api/client' +import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react' +import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' +function renderLucideIcon(icon:LucideIcon, props = {}) { + if (!_renderToStaticMarkup) return '' + return _renderToStaticMarkup( + createElement(icon, props) + ); +} + const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } function noteIconSvg(iconId) { - if (!_renderToStaticMarkup) return '' const Icon = NOTE_ICON_MAP[iconId] || FileText - return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })) + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }) } const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } function transportIconSvg(type) { - if (!_renderToStaticMarkup) return '' const Icon = TRANSPORT_ICON_MAP[type] || Ticket - return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })) + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }) +} + +const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound } +function accommodationIconSvg(type) { + const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' }) } // ── SVG inline icons (for chips) ───────────────────────────────────────────── @@ -115,6 +126,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) const range = longDateRange(sorted, loc) const coverImg = safeImg(trip?.cover_image) + //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed + const accommodations = await accommodationsApi.list(trip.id); // Pre-fetch place photos from Google const photoMap = await fetchPlacePhotos(assignments) @@ -223,7 +236,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${place.notes ? `
${escHtml(place.notes)}
` : ''}
` - }).join('') + }).join('') + + const accommodationsForDay = (accommodations.accommodations || []).filter(a => + days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) + ).sort((a, b) => a.start_day_id - b.start_day_id) + + const accommodationDetails = accommodationsForDay.map(item => { + const isCheckIn = day.id === item.start_day_id + const isCheckOut = day.id === item.end_day_id + const actionLabel = isCheckIn ? tr('reservations.meta.checkIn') + : isCheckOut ? tr('reservations.meta.checkOut') + : tr('reservations.meta.linkAccommodation') + const actionIcon = isCheckIn ? accommodationIconSvg('checkin') + : isCheckOut ? accommodationIconSvg('checkout') + : accommodationIconSvg('accommodation') + const timeStr = isCheckIn ? (item.check_in || '') + : isCheckOut ? (item.check_out || '') + : '' + + return ` +
+
${actionIcon} ${escHtml(actionLabel)}
+ ${timeStr ? `
${accommodationIconSvg('checkin')} ${escHtml(timeStr)}
` : ''} +
${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}
+ ${item.place_address ? `
${accommodationIconSvg('location')} ${escHtml(item.place_address)}
` : ''} + ${item.notes ? `
${accommodationIconSvg('note')} ${escHtml(item.notes)}
` : ''} + ${isCheckIn && item.confirmation ? `
${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}
` : ''} +
` + }).join('') + + const accommodationsHtml = accommodationsForDay.length > 0 + ? `
+
${accommodationDetails}
+
` + : '' return `
@@ -233,8 +280,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${day.date ? `${shortDate(day.date, loc)}` : ''} ${cost ? `${cost}` : ''}
-
${itemsHtml}
-
` +
${accommodationsHtml}${itemsHtml}
+
` }).join('') const html = ` @@ -317,6 +364,22 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); } .day-body { padding: 12px 28px 6px; } + /* accommodation info */ + .day-accommodations-overview { font-size: 12px; } + .day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; } + .day-accommodations.single { justify-content: center; } + .day-accommodation { + flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px; + border: 2px solid #e2e8f0; border-radius: 12px; + display: flex; flex-direction: column; + } + .day-accommodation-title { + font-size: 16px; font-weight: 600; text-align: center; + margin-bottom: 4px; align-self: center; + } + .accommodation-center-icon { display: flex; align-items: center; gap: 4px; } + + /* ── Place card ────────────────────────────────── */ .place-card { display: flex; align-items: stretch; diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 243ba34..ce85650 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -67,7 +67,134 @@ function katColor(kat, allCategories) { return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] } -interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null } +interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null } + +// ── Bag Card ────────────────────────────────────────────────────────────── + +interface BagCardProps { + bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number + tripMembers: TripMember[]; canEdit: boolean; onDelete: () => void + onUpdate: (bagId: number, data: Record) => void + onSetMembers: (bagId: number, userIds: number[]) => void; t: any; compact?: boolean +} + +function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, canEdit, onDelete, onUpdate, onSetMembers, t, compact }: BagCardProps) { + const [editingName, setEditingName] = useState(false) + const [nameVal, setNameVal] = useState(bag.name) + const [showUserPicker, setShowUserPicker] = useState(false) + useEffect(() => setNameVal(bag.name), [bag.name]) + + const saveName = () => { + if (nameVal.trim() && nameVal.trim() !== bag.name) onUpdate(bag.id, { name: nameVal.trim() }) + setEditingName(false) + } + + const memberIds = (bag.members || []).map(m => m.user_id) + const toggleMember = (userId: number) => { + const next = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId] + onSetMembers(bag.id, next) + } + + const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 } + + return ( +
+
+ + {editingName && canEdit ? ( + setNameVal(e.target.value)} + 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' }} /> + ) : ( + 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} + )} + + {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} + + {canEdit && } +
+ {/* Members */} +
+ {(bag.members || []).map(m => ( + canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}> + {m.avatar ? ( + {m.username} + ) : ( + + {m.username[0].toUpperCase()} + + )} + + ))} + {canEdit && ( + + )} + {showUserPicker && ( +
+ {tripMembers.map(m => { + const isSelected = memberIds.includes(m.id) + return ( + + ) + })} + {tripMembers.length === 0 &&
{t('packing.noMembers')}
} +
+ +
+
+ )} +
+
+
+
+
{bagItems.length} {t('admin.packingTemplates.items')}
+
+ ) +} + +// ── Quantity Input ───────────────────────────────────────────────────────── + +function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) { + const [local, setLocal] = useState(String(value)) + useEffect(() => setLocal(String(value)), [value]) + + const commit = () => { + const qty = Math.max(1, Math.min(999, Number(local) || 1)) + setLocal(String(qty)) + if (qty !== value) onSave(qty) + } + + return ( +
+ setLocal(e.target.value.replace(/\D/g, ''))} + onBlur={commit} + onKeyDown={e => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }} + style={{ width: 24, border: 'none', outline: 'none', background: 'transparent', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', color: 'var(--text-secondary)', padding: 0 }} + /> + x +
+ ) +} // ── Artikel-Zeile ────────────────────────────────────────────────────────── interface ArtikelZeileProps { @@ -154,6 +281,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE )} + {/* Quantity */} + {canEdit && updatePackingItem(tripId, item.id, { quantity: qty })} />} + {/* Weight + Bag (when enabled) */} {bagTrackingEnabled && (
@@ -738,10 +868,26 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } catch { toast.error(t('packing.toast.deleteError')) } } + const handleUpdateBag = async (bagId: number, data: Record) => { + try { + const result = await packingApi.updateBag(tripId, bagId, data) + setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b)) + } catch { toast.error(t('common.error')) } + } + + const handleSetBagMembers = async (bagId: number, userIds: number[]) => { + try { + const result = await packingApi.setBagMembers(tripId, bagId, userIds) + setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b)) + } catch { toast.error(t('common.error')) } + } + // Templates const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) + const [showSaveTemplate, setShowSaveTemplate] = useState(false) + const [saveTemplateName, setSaveTemplateName] = useState('') const [showImportModal, setShowImportModal] = useState(false) const [importText, setImportText] = useState('') const csvInputRef = useRef(null) @@ -775,10 +921,38 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } + const handleSaveAsTemplate = async () => { + if (!saveTemplateName.trim()) return + try { + await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()) + toast.success(t('packing.templateSaved')) + setShowSaveTemplate(false) + setSaveTemplateName('') + adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) + } catch { + toast.error(t('common.error')) + } + } + + // Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field) + const parseCsvLine = (line: string): string[] => { + const parts: string[] = [] + let current = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '"') { inQuotes = !inQuotes; continue } + if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) { parts.push(current.trim()); current = ''; continue } + current += ch + } + parts.push(current.trim()) + return parts + } + const parseImportLines = (text: string) => { return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => { // Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional) - const parts = line.split(/[,;\t]/).map(s => s.trim()) + const parts = parseCsvLine(line) if (parts.length >= 2) { const category = parts[0] const name = parts[1] @@ -885,6 +1059,32 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp )}
)} + {canEdit && items.length > 0 && ( +
+ {showSaveTemplate ? ( +
+ setSaveTemplateName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }} + placeholder={t('packing.templateName')} + style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }} + /> + + +
+ ) : ( + + )} +
+ )} {bagTrackingEnabled && ( - )} -
-
-
-
-
{bagItems.length} {t('admin.packingTemplates.items')}
-
+ handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact /> ) })} @@ -1095,25 +1277,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) return ( -
-
- - {bag.name} - - {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} - - {canEdit && ( - - )} -
-
-
-
-
{bagItems.length} {t('admin.packingTemplates.items')}
-
+ handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} /> ) })} @@ -1187,18 +1351,29 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp }} onClick={e => e.stopPropagation()}>
{t('packing.importTitle')}
{t('packing.importHint')}
-