diff --git a/Dockerfile b/Dockerfile index b45bd59..8a301c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,9 @@ FROM node:22-alpine WORKDIR /app -# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools) +# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools) COPY server/package*.json ./ -RUN apk add --no-cache python3 make g++ && \ +RUN apk add --no-cache tzdata su-exec python3 make g++ && \ npm ci --production && \ apk del python3 make g++ @@ -30,10 +30,14 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data +# Fix permissions on mounted volumes at runtime and run as node user +RUN chown -R node:node /app + # Umgebung setzen ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 -CMD ["node", "--import", "tsx", "src/index.ts"] +# Entrypoint: fix volume permissions then start as node +CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null; exec su-exec node node --import tsx src/index.ts"] diff --git a/MCP.md b/MCP.md new file mode 100644 index 0000000..0b64b52 --- /dev/null +++ b/MCP.md @@ -0,0 +1,239 @@ +# MCP Integration + +TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI +assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a +structured API. + +> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available. + +## Table of Contents + +- [Setup](#setup) +- [Limitations & Important Notes](#limitations--important-notes) +- [Resources (read-only)](#resources-read-only) +- [Tools (read-write)](#tools-read-write) +- [Example](#example) + +--- + +## Setup + +### 1. Enable the MCP addon (admin) + +An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` +endpoint returns `403 Forbidden` and the MCP section does not appear in user settings. + +### 2. Create an API token + +Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token: + +1. Click **Create New Token** +2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop") +3. **Copy the token immediately** — it is shown only once and cannot be recovered + +Each user can create up to **10 tokens**. + +### 3. Configure your MCP client + +The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your +`claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "https://your-trek-instance.com/mcp", + "--header", + "Authorization: Bearer trek_your_token_here" + ] + } + } +} +``` + +> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). + +--- + +## Limitations & Important Notes + +| Limitation | Details | +|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. | +| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. | +| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. | +| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. | +| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. | +| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. | +| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. | +| **Token limits** | Maximum 10 API tokens per user. | +| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. | +| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. | + +--- + +## Resources (read-only) + +Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before +making changes. + +| Resource | URI | Description | +|-------------------|--------------------------------------------|-----------------------------------------------------------| +| Trips | `trek://trips` | All trips you own or are a member of | +| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count | +| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places | +| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip | +| Budget | `trek://trips/{tripId}/budget` | Budget and expense items | +| Packing | `trek://trips/{tripId}/packing` | Packing checklist | +| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. | +| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day | +| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details | +| Members | `trek://trips/{tripId}/members` | Owner and collaborators | +| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes | +| Categories | `trek://categories` | Available place categories (for use when creating places) | +| Bucket List | `trek://bucket-list` | Your personal travel bucket list | +| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas | + +--- + +## Tools (read-write) + +TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything +about a trip in a single call. + +### Trip Summary + +| Tool | Description | +|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. | + +### Trips + +| Tool | Description | +|---------------|---------------------------------------------------------------------------------------------| +| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. | +| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. | +| `update_trip` | Update a trip's title, description, dates, or currency. | +| `delete_trip` | Delete a trip. **Owner only.** | + +### Places + +| Tool | Description | +|----------------|-----------------------------------------------------------------------------------| +| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. | +| `update_place` | Update any field of an existing place. | +| `delete_place` | Remove a place from a trip. | + +### Day Planning + +| Tool | Description | +|---------------------------|-------------------------------------------------------------------------------| +| `assign_place_to_day` | Pin a place to a specific day in the itinerary. | +| `unassign_place` | Remove a place assignment from a day. | +| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. | +| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). | +| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). | + +### Reservations + +| Tool | Description | +|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. | +| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | +| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | +| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. | + +### Budget + +| Tool | Description | +|----------------------|--------------------------------------------------------------| +| `create_budget_item` | Add an expense with name, category, and price. | +| `update_budget_item` | Update an expense's details, split (persons/days), or notes. | +| `delete_budget_item` | Remove a budget item. | + +### Packing + +| Tool | Description | +|-----------------------|--------------------------------------------------------------| +| `create_packing_item` | Add an item to the packing checklist with optional category. | +| `update_packing_item` | Rename an item or change its category. | +| `toggle_packing_item` | Check or uncheck a packing item. | +| `delete_packing_item` | Remove a packing item. | + +### Day Notes + +| Tool | Description | +|-------------------|-----------------------------------------------------------------------| +| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. | +| `update_day_note` | Edit a day note's text, time, or icon. | +| `delete_day_note` | Remove a note from a day. | + +### Collab Notes + +| Tool | Description | +|----------------------|-------------------------------------------------------------------------------------------------| +| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. | +| `update_collab_note` | Edit a collab note's content, category, color, or pin status. | +| `delete_collab_note` | Delete a collab note and its associated files. | + +### Bucket List + +| Tool | Description | +|---------------------------|--------------------------------------------------------------------------------------------| +| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. | +| `delete_bucket_list_item` | Remove an item from your bucket list. | + +### Atlas + +| Tool | Description | +|--------------------------|--------------------------------------------------------------------------------| +| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). | +| `unmark_country_visited` | Remove a country from your visited list. | + +--- + +## Example + +Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d + +Initial prompt (1st message): + +``` +I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027 +and leaving April 11 2027. It's cherry blossom season so please keep that +in mind when picking spots. + +Before writing anything to TREK, do some research: look up what's worth +visiting, figure out a logical day-by-day flow (group nearby spots together +to avoid unnecessary travel), find a well-reviewed hotel in a central +neighbourhood, and think about what kind of food and restaurant experiences +are worth including. + +Once you have a solid plan, write the whole thing to TREK: +- Create the trip +- Add all the places you've researched with their real coordinates +- Build out the daily itinerary with sensible visiting times +- Book the hotel as a reservation and link it properly to the accommodation days +- Add any notable restaurant reservations +- Put together a realistic budget in EUR +- Build a packing list suited to April in Kyoto +- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.) +- Add a day note for each day with any important heads-up (early start, crowd + tips, booking requirements, etc.) +- Mark Japan as visited in my Atlas + +Currency: CHF. Use get_trip_summary at the end and give me a quick recap +of everything that was added. +``` + +Database file: https://share.jubnl.ch/s/S7bBpj42mB + +Email: admin@admin.com \ +Password: admin123 + +PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf) + +![trip](./docs/screenshot-trip-mcp.png) \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 9a80671..5dc9bc0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/package.json b/client/package.json index e0c1b6e..a2f674c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.7.0", + "version": "2.7.1", "private": true, "type": "module", "scripts": { diff --git a/client/src/App.tsx b/client/src/App.tsx index 47c2f8d..a02662f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' +import SharedTripPage from './pages/SharedTripPage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import DemoBanner from './components/Layout/DemoBanner' @@ -22,8 +23,9 @@ interface ProtectedRouteProps { } function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) { - const { isAuthenticated, user, isLoading } = useAuthStore() + const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore() const { t } = useTranslation() + const location = useLocation() if (isLoading) { return ( @@ -40,6 +42,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps return } + if ( + appRequireMfa && + user && + !user.mfa_enabled && + location.pathname !== '/settings' + ) { + return + } + if (adminRequired && user && user.role !== 'admin') { return } @@ -62,16 +73,38 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore() + const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { if (token) { loadUser() } - authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) + if (config?.timezone) setServerTimezone(config.timezone) + if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) + + if (config?.version) { + const storedVersion = localStorage.getItem('trek_app_version') + if (storedVersion && storedVersion !== config.version) { + try { + if ('caches' in window) { + const names = await caches.keys() + await Promise.all(names.map(n => caches.delete(n))) + } + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations() + await Promise.all(regs.map(r => r.unregister())) + } + } catch {} + localStorage.setItem('trek_app_version', config.version) + window.location.reload() + return + } + localStorage.setItem('trek_app_version', config.version) + } }).catch(() => {}) }, []) @@ -83,7 +116,18 @@ export default function App() { } }, [isAuthenticated]) + const location = useLocation() + const isSharedPage = location.pathname.startsWith('/shared/') + useEffect(() => { + // Shared page always forces light mode + if (isSharedPage) { + document.documentElement.classList.remove('dark') + const meta = document.querySelector('meta[name="theme-color"]') + if (meta) meta.setAttribute('content', '#ffffff') + return + } + const mode = settings.dark_mode const applyDark = (isDark: boolean) => { document.documentElement.classList.toggle('dark', isDark) @@ -99,7 +143,7 @@ export default function App() { return () => mq.removeEventListener('change', handler) } applyDark(mode === true || mode === 'dark') - }, [settings.dark_mode]) + }, [settings.dark_mode, isSharedPage]) return ( @@ -107,6 +151,7 @@ export default function App() { } /> } /> + } /> } /> apiClient.post('/auth/login', data).then(r => r.data), verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), - mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data), + mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), @@ -61,6 +68,11 @@ export const authApi = { changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), + mcpTokens: { + list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data), + create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data), + delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), + }, } export const tripsApi = { @@ -91,6 +103,10 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), + importGpx: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, } export const assignmentsApi = { @@ -108,6 +124,7 @@ export const assignmentsApi = { export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), + bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), @@ -163,6 +180,10 @@ export const adminApi = { listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), + auditLog: (params?: { limit?: number; offset?: number }) => + apiClient.get('/admin/audit-log', { params }).then(r => r.data), + mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), + deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), } export const addonsApi = { @@ -174,6 +195,7 @@ export const mapsApi = { details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), + resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data), } export const budgetApi = { @@ -184,6 +206,7 @@ export const budgetApi = { setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), + settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), } export const filesApi = { @@ -207,6 +230,7 @@ export const reservationsApi = { create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), + updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data), } export const weatherApi = { @@ -281,4 +305,17 @@ export const backupApi = { setAutoSettings: (settings: Record) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), } +export const shareApi = { + getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data), + createLink: (tripId: number | string, perms?: Record) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data), + deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data), + getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), +} + +export const notificationsApi = { + getPreferences: () => apiClient.get('/notifications/preferences').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), +} + export default apiClient diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index ad9e539..3050258 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, } interface Addon { @@ -32,6 +33,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dm = useSettingsStore(s => s.settings.dark_mode) 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 [loading, setLoading] = useState(true) @@ -57,7 +59,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - window.dispatchEvent(new Event('addons-changed')) + refreshGlobalAddons() toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { // Rollback @@ -68,6 +70,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const integrationAddons = addons.filter(a => a.type === 'integration') if (loading) { return ( @@ -144,6 +147,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } ))} )} + + {/* Integration Addons */} + {integrationAddons.length > 0 && ( +
+
+ + + {t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')} + +
+ {integrationAddons.map(addon => ( + + ))} +
+ )} )} @@ -188,11 +206,8 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { Coming Soon )} - - {addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')} + + {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}

{label.description}

diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx new file mode 100644 index 0000000..8a89f92 --- /dev/null +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react' +import { adminApi } from '../../api/client' +import { useToast } from '../shared/Toast' +import { Key, Trash2, User, Loader2 } from 'lucide-react' +import { useTranslation } from '../../i18n' + +interface AdminMcpToken { + id: number + name: string + token_prefix: string + created_at: string + last_used_at: string | null + user_id: number + username: string +} + +export default function AdminMcpTokensPanel() { + const [tokens, setTokens] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const toast = useToast() + const { t, locale } = useTranslation() + + useEffect(() => { + setIsLoading(true) + adminApi.mcpTokens() + .then(d => setTokens(d.tokens || [])) + .catch(() => toast.error(t('admin.mcpTokens.loadError'))) + .finally(() => setIsLoading(false)) + }, []) + + const handleDelete = async (id: number) => { + try { + await adminApi.deleteMcpToken(id) + setTokens(prev => prev.filter(tk => tk.id !== id)) + setDeleteConfirmId(null) + toast.success(t('admin.mcpTokens.deleteSuccess')) + } catch { + toast.error(t('admin.mcpTokens.deleteError')) + } + } + + return ( +
+
+

{t('admin.mcpTokens.title')}

+

{t('admin.mcpTokens.subtitle')}

+
+ +
+ {isLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +
+ +

{t('admin.mcpTokens.empty')}

+
+ ) : ( + <> +
+ {t('admin.mcpTokens.tokenName')} + {t('admin.mcpTokens.owner')} + {t('admin.mcpTokens.created')} + {t('admin.mcpTokens.lastUsed')} + +
+ {tokens.map((token, i) => ( +
+
+

{token.name}

+

{token.token_prefix}...

+
+
+ + {token.username} +
+ + {new Date(token.created_at).toLocaleDateString(locale)} + + + {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} + + +
+ ))} + + )} +
+ + {deleteConfirmId !== null && ( +
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> +
+

{t('admin.mcpTokens.deleteTitle')}

+

{t('admin.mcpTokens.deleteMessage')}

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx new file mode 100644 index 0000000..f36d69e --- /dev/null +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { RefreshCw, ClipboardList } from 'lucide-react' + +interface AuditEntry { + id: number + created_at: string + user_id: number | null + username: string | null + user_email: string | null + action: string + resource: string | null + details: Record | null + ip: string | null +} + +export default function AuditLogPanel(): React.ReactElement { + const { t, locale } = useTranslation() + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const limit = 100 + + const loadFirstPage = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: 0 }) as { + entries: AuditEntry[] + total: number + } + setEntries(data.entries || []) + setTotal(data.total ?? 0) + setOffset(0) + } catch { + setEntries([]) + setTotal(0) + setOffset(0) + } finally { + setLoading(false) + } + }, []) + + const loadMore = useCallback(async () => { + const nextOffset = offset + limit + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { + entries: AuditEntry[] + total: number + } + setEntries((prev) => [...prev, ...(data.entries || [])]) + setTotal(data.total ?? 0) + setOffset(nextOffset) + } catch { + /* keep existing */ + } finally { + setLoading(false) + } + }, [offset]) + + useEffect(() => { + loadFirstPage() + }, [loadFirstPage]) + + const fmtTime = (iso: string) => { + try { + return new Date(iso).toLocaleString(locale, { + dateStyle: 'short', + timeStyle: 'medium', + }) + } catch { + return iso + } + } + + const fmtDetails = (d: Record | null) => { + if (!d || Object.keys(d).length === 0) return '—' + try { + return JSON.stringify(d) + } catch { + return '—' + } + } + + const userLabel = (e: AuditEntry) => { + if (e.username) return e.username + if (e.user_email) return e.user_email + if (e.user_id != null) return `#${e.user_id}` + return '—' + } + + return ( +
+
+
+

+ + {t('admin.tabs.audit')} +

+

{t('admin.audit.subtitle')}

+
+ +
+ +

+ {t('admin.audit.showing', { count: entries.length, total })} +

+ + {loading && entries.length === 0 ? ( +
{t('common.loading')}
+ ) : entries.length === 0 ? ( +
{t('admin.audit.empty')}
+ ) : ( +
+ + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
+
+ )} + + {entries.length < total && ( + + )} +
+ ) +} diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 89af898..c1fd048 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -3,6 +3,8 @@ import { backupApi } from '../../api/client' import { useToast } from '../shared/Toast' import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import CustomSelect from '../shared/CustomSelect' import { getApiErrorMessage } from '../../types' const INTERVAL_OPTIONS = [ @@ -21,19 +23,35 @@ const KEEP_OPTIONS = [ { value: 0, labelKey: 'backup.keep.forever' }, ] +const DAYS_OF_WEEK = [ + { value: 0, labelKey: 'backup.dow.sunday' }, + { value: 1, labelKey: 'backup.dow.monday' }, + { value: 2, labelKey: 'backup.dow.tuesday' }, + { value: 3, labelKey: 'backup.dow.wednesday' }, + { value: 4, labelKey: 'backup.dow.thursday' }, + { value: 5, labelKey: 'backup.dow.friday' }, + { value: 6, labelKey: 'backup.dow.saturday' }, +] + +const HOURS = Array.from({ length: 24 }, (_, i) => i) + +const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1) + export default function BackupPanel() { const [backups, setBackups] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isCreating, setIsCreating] = useState(false) const [restoringFile, setRestoringFile] = useState(null) const [isUploading, setIsUploading] = useState(false) - const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 }) + const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) + const [serverTimezone, setServerTimezone] = useState('') const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } const fileInputRef = useRef(null) const toast = useToast() const { t, language, locale } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const loadBackups = async () => { setIsLoading(true) @@ -51,6 +69,7 @@ export default function BackupPanel() { try { const data = await backupApi.getAutoSettings() setAutoSettings(data.settings) + if (data.timezone) setServerTimezone(data.timezone) } catch {} } @@ -147,10 +166,12 @@ export default function BackupPanel() { const formatDate = (dateStr) => { if (!dateStr) return '-' try { - return new Date(dateStr).toLocaleString(locale, { + const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', - }) + } + if (serverTimezone) opts.timeZone = serverTimezone + return new Date(dateStr).toLocaleString(locale, opts) } catch { return dateStr } } @@ -331,6 +352,68 @@ export default function BackupPanel() { + {/* Hour picker (for daily, weekly, monthly) */} + {autoSettings.interval !== 'hourly' && ( +
+ + handleAutoSettingsChange('hour', parseInt(v, 10))} + size="sm" + options={HOURS.map(h => { + let label: string + if (is12h) { + const period = h >= 12 ? 'PM' : 'AM' + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + label = `${h12}:00 ${period}` + } else { + label = `${String(h).padStart(2, '0')}:00` + } + return { value: String(h), label } + })} + /> +

+ {t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''} +

+
+ )} + + {/* Day of week (for weekly) */} + {autoSettings.interval === 'weekly' && ( +
+ +
+ {DAYS_OF_WEEK.map(opt => ( + + ))} +
+
+ )} + + {/* Day of month (for monthly) */} + {autoSettings.interval === 'monthly' && ( +
+ + handleAutoSettingsChange('day_of_month', parseInt(v, 10))} + size="sm" + options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))} + /> +

{t('backup.auto.dayOfMonthHint')}

+
+ )} + {/* Keep duration */}
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 171375f..a6a8a9e 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' import type { BudgetItem, BudgetMember } from '../../types' @@ -29,8 +29,23 @@ interface PerPersonSummaryEntry { } // ── Helpers ────────────────────────────────────────────────────────────────── -const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] -const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' } +const CURRENCIES = [ + 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', + 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', + 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', + 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT', + 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', +] +const SYMBOLS = { + EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', + SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$', + NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM', + PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', + ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', + HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', + UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$', + PEN: 'S/.', ARS: 'AR$', +} const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] const fmtNum = (v, locale, cur) => { @@ -145,9 +160,11 @@ interface ChipWithTooltipProps { label: string avatarUrl: string | null size?: number + paid?: boolean + onClick?: () => void } -function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) { +function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) @@ -160,13 +177,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) setHover(true) } + const borderColor = paid ? '#22c55e' : 'var(--border-primary)' + const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + return ( <>
setHover(false)} + onClick={onClick} style={{ - width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)', - background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', }}> {avatarUrl ? @@ -177,11 +200,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
{label} + {paid && ( + Paid + )}
, document.body )} @@ -194,10 +225,11 @@ interface BudgetMemberChipsProps { members?: BudgetMember[] tripMembers?: TripMember[] onSetMembers: (memberIds: number[]) => void + onTogglePaid?: (userId: number, paid: boolean) => void compact?: boolean } -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) { +function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) { const chipSize = compact ? 20 : 30 const btnSize = compact ? 18 : 28 const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) @@ -237,7 +269,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa return (
{members.map(m => ( - + onTogglePaid(m.user_id, !m.paid) : undefined} + /> ))}
@@ -553,6 +597,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro members={item.members || []} tripMembers={tripMembers} onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} /> ) : ( handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> @@ -628,6 +673,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( )} + + {/* Settlement dropdown inside the total card */} + {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( +
+ + + {settlementOpen && ( +
+ {settlement.flows.map((flow, i) => ( +
+ +
+ + + {fmt(flow.amount, currency)} + + +
+ +
+ ))} + + {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( +
+
+ {t('budget.netBalances')} +
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => ( +
+
+ {b.avatar_url + ? + : b.username?.[0]?.toUpperCase() + } +
+ + {b.username} + + 0 ? '#4ade80' : '#f87171', + }}> + {b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)} + +
+ ))} +
+ )} +
+ )} +
+ )}
{pieSegments.length > 0 && ( @@ -641,27 +771,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro -
+
{pieSegments.map(seg => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' return (
{seg.name} - {pct}% + {fmt(seg.value, currency)} + {pct}%
) })}
- -
- {pieSegments.map(seg => ( -
- {seg.name} - {fmt(seg.value, currency)} -
- ))} -
)} diff --git a/client/src/components/Dashboard/CurrencyWidget.tsx b/client/src/components/Dashboard/CurrencyWidget.tsx index a9a648d..6162fc0 100644 --- a/client/src/components/Dashboard/CurrencyWidget.tsx +++ b/client/src/components/Dashboard/CurrencyWidget.tsx @@ -14,7 +14,7 @@ const CURRENCIES = [ const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c })) export default function CurrencyWidget() { - const { t } = useTranslation() + 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') @@ -40,7 +40,7 @@ export default function CurrencyWidget() { const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null const formatNumber = (num) => { if (!num || num === '—') return '—' - return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const result = rawResult diff --git a/client/src/components/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx index 087937a..f3b3a8e 100644 --- a/client/src/components/Dashboard/TimezoneWidget.tsx +++ b/client/src/components/Dashboard/TimezoneWidget.tsx @@ -1,6 +1,7 @@ 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' }, @@ -23,9 +24,9 @@ const POPULAR_ZONES = [ { label: 'Cairo', tz: 'Africa/Cairo' }, ] -function getTime(tz, locale) { +function getTime(tz, locale, is12h) { try { - return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' }) + return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }) } catch { return '—' } } @@ -42,6 +43,7 @@ function getOffset(tz) { 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) : [ @@ -87,7 +89,7 @@ export default function TimezoneWidget() { const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz)) - const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) + 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) @@ -113,7 +115,7 @@ export default function TimezoneWidget() { {zones.map(z => (
-

{getTime(z.tz, locale)}

+

{getTime(z.tz, locale, is12h)}

{z.label} {getOffset(z.tz)}

))}
diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 0e85d14..ea19596 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -3,8 +3,8 @@ import ReactDOM from 'react-dom' import { Link, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' -import { addonsApi } from '../../api/client' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' import type { LucideIcon } from 'lucide-react' @@ -28,29 +28,21 @@ interface Addon { export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { const { user, logout } = useAuthStore() const { settings, updateSetting } = useSettingsStore() + const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const navigate = useNavigate() const location = useLocation() const [userMenuOpen, setUserMenuOpen] = useState(false) const [appVersion, setAppVersion] = useState(null) - const [globalAddons, setGlobalAddons] = useState([]) const darkMode = settings.dark_mode const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const loadAddons = () => { - if (user) { - addonsApi.enabled().then(data => { - setGlobalAddons(data.addons.filter(a => a.type === 'global')) - }).catch(() => {}) - } - } - useEffect(loadAddons, [user, location.pathname]) - // Listen for addon changes from AddonManager + // Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page + const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled) + useEffect(() => { - const handler = () => loadAddons() - window.addEventListener('addons-changed', handler) - return () => window.removeEventListener('addons-changed', handler) - }, [user]) + if (user) loadAddons() + }, [user, location.pathname]) useEffect(() => { import('../../api/client').then(({ authApi }) => { diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 6ca4238..26e744a 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState, useMemo } from 'react' +import { useEffect, useRef, useState, useMemo, useCallback } from 'react' import DOM from 'react-dom' -import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet' +import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' import 'leaflet.markercluster/dist/MarkerCluster.css' @@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { cursor:pointer;flex-shrink:0;position:relative; ">
- +
${badgeHtml}
`, @@ -240,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { const mapPhotoCache = new Map() const mapPhotoInFlight = new Set() +// Live location tracker — blue dot with pulse animation (like Apple/Google Maps) +function LocationTracker() { + const map = useMap() + const [position, setPosition] = useState<[number, number] | null>(null) + const [accuracy, setAccuracy] = useState(0) + const [tracking, setTracking] = useState(false) + const watchId = useRef(null) + + const startTracking = useCallback(() => { + if (!('geolocation' in navigator)) return + setTracking(true) + watchId.current = navigator.geolocation.watchPosition( + (pos) => { + const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude] + setPosition(latlng) + setAccuracy(pos.coords.accuracy) + }, + () => setTracking(false), + { enableHighAccuracy: true, maximumAge: 5000 } + ) + }, []) + + const stopTracking = useCallback(() => { + if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) + watchId.current = null + setTracking(false) + setPosition(null) + }, []) + + const toggleTracking = useCallback(() => { + if (tracking) { stopTracking() } else { startTracking() } + }, [tracking, startTracking, stopTracking]) + + // Center map on position when first acquired + const centered = useRef(false) + useEffect(() => { + if (position && !centered.current) { + map.setView(position, 15) + centered.current = true + } + }, [position, map]) + + // Cleanup on unmount + useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, []) + + return ( + <> + {/* Location button */} +
+ +
+ + {/* Blue dot + accuracy circle */} + {position && ( + <> + {accuracy < 500 && ( + + )} + + + )} + + {/* Pulse animation CSS */} + {position && ( + + )} + + ) +} + export function MapView({ places = [], dayPlaces = [], @@ -270,33 +360,48 @@ export function MapView({ }, [leftWidth, rightWidth, hasInspector]) const [photoUrls, setPhotoUrls] = useState({}) - // Fetch photos for places (Google or Wikimedia Commons fallback) + // Fetch photos for places with concurrency limit to avoid blocking map rendering useEffect(() => { - places.forEach(place => { - if (place.image_url) return + const queue = places.filter(place => { + if (place.image_url) return false const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - if (!cacheKey) return + if (!cacheKey) return false if (mapPhotoCache.has(cacheKey)) { const cached = mapPhotoCache.get(cacheKey) if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) - return + return false } - if (mapPhotoInFlight.has(cacheKey)) return + if (mapPhotoInFlight.has(cacheKey)) return false const photoId = place.google_place_id || place.osm_id - if (!photoId && !(place.lat && place.lng)) return - mapPhotoInFlight.add(cacheKey) - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then(data => { - if (data.photoUrl) { - mapPhotoCache.set(cacheKey, data.photoUrl) - setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) - } else { - mapPhotoCache.set(cacheKey, null) - } - mapPhotoInFlight.delete(cacheKey) - }) - .catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) }) + if (!photoId && !(place.lat && place.lng)) return false + return true }) + + let active = 0 + const MAX_CONCURRENT = 3 + let idx = 0 + + const fetchNext = () => { + while (active < MAX_CONCURRENT && idx < queue.length) { + const place = queue[idx++] + const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const photoId = place.google_place_id || place.osm_id + mapPhotoInFlight.add(cacheKey) + active++ + mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) + .then(data => { + if (data.photoUrl) { + mapPhotoCache.set(cacheKey, data.photoUrl) + setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) + } else { + mapPhotoCache.set(cacheKey, null) + } + }) + .catch(() => { mapPhotoCache.set(cacheKey, null) }) + .finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() }) + } + } + fetchNext() }, [places]) return ( @@ -318,6 +423,7 @@ export function MapView({ + ` const svgClock = `` @@ -96,13 +103,14 @@ interface downloadTripPDFProps { assignments: AssignmentsMap categories: Category[] dayNotes: DayNotesMap + reservations?: any[] t: (key: string, params?: Record) => string locale: string } -export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) { +export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) { await ensureRenderer() - const loc = _locale || 'de-DE' + const loc = _locale || undefined const tr = _t || (k => k) const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) const range = longDateRange(sorted, loc) @@ -123,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) + // Transport bookings for this day + const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) + const dayTransport = (reservations || []).filter(r => { + if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false + return day.date && r.reservation_time.split('T')[0] === day.date + }) + const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) + dayTransport.forEach(r => { + const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) + merged.push({ type: 'transport', k: pos, data: r }) + }) merged.sort((a, b) => a.k - b.k) let pi = 0 const itemsHtml = merged.length === 0 ? `
${escHtml(tr('dayplan.emptyDay'))}
` : merged.map(item => { + if (item.type === 'transport') { + const r = item.data + const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) + const icon = transportIconSvg(r.type) + let subtitle = '' + if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') + else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ') + const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + return ` +
+
+ ${icon} +
+
${escHtml(r.title)}${time ? ` ${time}` : ''}
+ ${subtitle ? `
${escHtml(subtitle)}
` : ''} + ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''} +
+
` + } + if (item.type === 'note') { const note = item.data return ` diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 7d670db..d1cc0a0 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -3,9 +3,10 @@ import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { packingApi, tripsApi, adminApi } from '../../api/client' +import ReactDOM from 'react-dom' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, - X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, + X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload, } from 'lucide-react' import type { PackingItem } from '../../types' @@ -727,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) + const [importText, setImportText] = useState('') + const csvInputRef = useRef(null) const templateDropdownRef = useRef(null) useEffect(() => { @@ -757,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } + 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()) + if (parts.length >= 2) { + const category = parts[0] + const name = parts[1] + const weight_grams = parts[2] || undefined + const bag = parts[3] || undefined + const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1' + return { name, category, weight_grams, bag, checked } + } + // Single value = just a name + return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false } + }).filter(i => i.name) + } + + const handleBulkImport = async () => { + const parsed = parseImportLines(importText) + if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } + try { + const result = await packingApi.bulkImport(tripId, parsed) + toast.success(t('packing.importSuccess', { count: result.count })) + setImportText('') + setShowImportModal(false) + window.location.reload() + } catch { toast.error(t('packing.importError')) } + } + + const handleCsvFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + const reader = new FileReader() + reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) } + reader.readAsText(file) + } + const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } return ( @@ -781,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp {t('packing.clearCheckedShort', { count: abgehakt })} )} + {availableTemplates.length > 0 && (