diff --git a/Dockerfile b/Dockerfile index 3a6c7ff..b45bd59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,9 @@ COPY --from=client-builder /app/client/dist ./public # Fonts für PDF-Export kopieren COPY --from=client-builder /app/client/public/fonts ./public/fonts -# Verzeichnisse erstellen -RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers +# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads) +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 # Umgebung setzen ENV NODE_ENV=production @@ -35,4 +36,4 @@ ENV PORT=3000 EXPOSE 3000 -CMD ["node", "src/index.js"] +CMD ["node", "--import", "tsx", "src/index.ts"] diff --git a/client/index.html b/client/index.html index 59b5ef4..89c3c58 100644 --- a/client/index.html +++ b/client/index.html @@ -25,6 +25,6 @@
- + diff --git a/client/package-lock.json b/client/package-lock.json index af87f94..b3defeb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -26,11 +26,13 @@ "@types/leaflet": "^1.9.8", "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.18", "postcss": "^8.4.35", "sharp": "^0.33.0", "tailwindcss": "^3.4.1", + "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0" } @@ -3266,6 +3268,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7534,6 +7546,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index 5420606..febb80b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "nomad-client", - "version": "2.6.0", + "version": "2.6.1", "private": true, "type": "module", "scripts": { @@ -28,11 +28,13 @@ "@types/leaflet": "^1.9.8", "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.18", "postcss": "^8.4.35", "sharp": "^0.33.0", "tailwindcss": "^3.4.1", + "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0" } diff --git a/client/src/App.jsx b/client/src/App.tsx similarity index 91% rename from client/src/App.jsx rename to client/src/App.tsx index 9f59301..1c09ed7 100644 --- a/client/src/App.jsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, ReactNode } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' @@ -6,7 +6,6 @@ import LoginPage from './pages/LoginPage' import RegisterPage from './pages/RegisterPage' import DashboardPage from './pages/DashboardPage' import TripPlannerPage from './pages/TripPlannerPage' -// PhotosPage removed - replaced by Finanzplan import FilesPage from './pages/FilesPage' import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' @@ -17,7 +16,12 @@ import { TranslationProvider, useTranslation } from './i18n' import DemoBanner from './components/Layout/DemoBanner' import { authApi } from './api/client' -function ProtectedRoute({ children, adminRequired = false }) { +interface ProtectedRouteProps { + children: ReactNode + adminRequired?: boolean +} + +function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) { const { isAuthenticated, user, isLoading } = useAuthStore() const { t } = useTranslation() @@ -40,7 +44,7 @@ function ProtectedRoute({ children, adminRequired = false }) { return } - return children + return <>{children} } function RootRedirect() { @@ -65,7 +69,7 @@ export default function App() { if (token) { loadUser() } - authApi.getAppConfig().then(config => { + authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) }).catch(() => {}) @@ -79,10 +83,9 @@ export default function App() { } }, [isAuthenticated]) - // Apply dark mode class to + update PWA theme-color useEffect(() => { const mode = settings.dark_mode - const applyDark = (isDark) => { + const applyDark = (isDark: boolean) => { document.documentElement.classList.toggle('dark', isDark) const meta = document.querySelector('meta[name="theme-color"]') if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff') @@ -91,11 +94,10 @@ export default function App() { if (mode === 'auto') { const mq = window.matchMedia('(prefers-color-scheme: dark)') applyDark(mq.matches) - const handler = (e) => applyDark(e.matches) + const handler = (e: MediaQueryListEvent) => applyDark(e.matches) mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) } - // Support legacy boolean + new string values applyDark(mode === true || mode === 'dark') }, [settings.dark_mode]) diff --git a/client/src/api/client.js b/client/src/api/client.js deleted file mode 100644 index 7b44777..0000000 --- a/client/src/api/client.js +++ /dev/null @@ -1,251 +0,0 @@ -import axios from 'axios' -import { getSocketId } from './websocket' - -const apiClient = axios.create({ - baseURL: '/api', - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - add auth token and socket ID -apiClient.interceptors.request.use( - (config) => { - const token = localStorage.getItem('auth_token') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - const sid = getSocketId() - if (sid) { - config.headers['X-Socket-Id'] = sid - } - return config - }, - (error) => Promise.reject(error) -) - -// Response interceptor - handle 401 -apiClient.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('auth_token') - if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) { - window.location.href = '/login' - } - } - return Promise.reject(error) - } -) - -export const authApi = { - register: (data) => apiClient.post('/auth/register', data).then(r => r.data), - login: (data) => apiClient.post('/auth/login', data).then(r => r.data), - me: () => apiClient.get('/auth/me').then(r => r.data), - updateMapsKey: (key) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), - updateApiKeys: (data) => apiClient.put('/auth/me/api-keys', data).then(r => r.data), - updateSettings: (data) => apiClient.put('/auth/me/settings', data).then(r => r.data), - getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data), - listUsers: () => apiClient.get('/auth/users').then(r => r.data), - uploadAvatar: (formData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data), - getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data), - updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data), - validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), - travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), - changePassword: (data) => 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), -} - -export const tripsApi = { - list: (params) => apiClient.get('/trips', { params }).then(r => r.data), - create: (data) => apiClient.post('/trips', data).then(r => r.data), - get: (id) => apiClient.get(`/trips/${id}`).then(r => r.data), - update: (id, data) => apiClient.put(`/trips/${id}`, data).then(r => r.data), - delete: (id) => apiClient.delete(`/trips/${id}`).then(r => r.data), - uploadCover: (id, formData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - archive: (id) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), - unarchive: (id) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), - getMembers: (id) => apiClient.get(`/trips/${id}/members`).then(r => r.data), - addMember: (id, identifier) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), - removeMember: (id, userId) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), -} - -export const daysApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), - update: (tripId, dayId, data) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), - delete: (tripId, dayId) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), -} - -export const placesApi = { - list: (tripId, params) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), - get: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), - searchImage: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), -} - -export const assignmentsApi = { - list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), - create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), - delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), - reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), - move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), - update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), - getParticipants: (tripId, id) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), - setParticipants: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), - updateTime: (tripId, id, times) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), -} - -export const packingApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), - reorder: (tripId, orderedIds) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), -} - -export const tagsApi = { - list: () => apiClient.get('/tags').then(r => r.data), - create: (data) => apiClient.post('/tags', data).then(r => r.data), - update: (id, data) => apiClient.put(`/tags/${id}`, data).then(r => r.data), - delete: (id) => apiClient.delete(`/tags/${id}`).then(r => r.data), -} - -export const categoriesApi = { - list: () => apiClient.get('/categories').then(r => r.data), - create: (data) => apiClient.post('/categories', data).then(r => r.data), - update: (id, data) => apiClient.put(`/categories/${id}`, data).then(r => r.data), - delete: (id) => apiClient.delete(`/categories/${id}`).then(r => r.data), -} - -export const adminApi = { - users: () => apiClient.get('/admin/users').then(r => r.data), - createUser: (data) => apiClient.post('/admin/users', data).then(r => r.data), - updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), - deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), - stats: () => apiClient.get('/admin/stats').then(r => r.data), - saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), - getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), - updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data), - addons: () => apiClient.get('/admin/addons').then(r => r.data), - updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), - checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), - installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), -} - -export const addonsApi = { - enabled: () => apiClient.get('/addons').then(r => r.data), -} - -export const mapsApi = { - search: (query, lang) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), - details: (placeId, lang) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data), - placePhoto: (placeId) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data), -} - -export const budgetApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), - setMembers: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), - togglePaid: (tripId, id, userId, paid) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), - perPersonSummary: (tripId) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), -} - -export const filesApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data), - upload: (tripId, formData) => apiClient.post(`/trips/${tripId}/files`, formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), -} - -export const reservationsApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), -} - -export const weatherApi = { - get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), - getDetailed: (lat, lng, date, lang) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), -} - -export const settingsApi = { - get: () => apiClient.get('/settings').then(r => r.data), - set: (key, value) => apiClient.put('/settings', { key, value }).then(r => r.data), - setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), -} - -export const accommodationsApi = { - list: (tripId) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), - create: (tripId, data) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), - update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), - delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), -} - -export const dayNotesApi = { - list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), - create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), - update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), - delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), -} - -export const collabApi = { - // Notes - getNotes: (tripId) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), - createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), - updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), - deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), - uploadNoteFile: (tripId, noteId, formData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), - deleteNoteFile: (tripId, noteId, fileId) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), - // Polls - getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), - createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), - votePoll: (tripId, id, optionIndex) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), - closePoll: (tripId, id) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), - deletePoll: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), - // Chat - getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), - sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), - deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), - reactMessage: (tripId, id, emoji) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), - linkPreview: (tripId, url) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), -} - -export const backupApi = { - list: () => apiClient.get('/backup/list').then(r => r.data), - create: () => apiClient.post('/backup/create').then(r => r.data), - download: async (filename) => { - const token = localStorage.getItem('auth_token') - const res = await fetch(`/api/backup/download/${filename}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Download failed') - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) - }, - delete: (filename) => apiClient.delete(`/backup/${filename}`).then(r => r.data), - restore: (filename) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data), - uploadRestore: (file) => { - const form = new FormData() - form.append('backup', file) - return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) - }, - getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data), - setAutoSettings: (settings) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), -} - -export default apiClient diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..534fca0 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,248 @@ +import axios, { AxiosInstance } from 'axios' +import { getSocketId } from './websocket' + +const apiClient: AxiosInstance = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor - add auth token and socket ID +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('auth_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + const sid = getSocketId() + if (sid) { + config.headers['X-Socket-Id'] = sid + } + return config + }, + (error) => Promise.reject(error) +) + +// Response interceptor - handle 401 +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('auth_token') + if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) { + window.location.href = '/login' + } + } + return Promise.reject(error) + } +) + +export const authApi = { + register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data), + login: (data: { email: string; password: string }) => apiClient.post('/auth/login', 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), + updateApiKeys: (data: Record) => apiClient.put('/auth/me/api-keys', data).then(r => r.data), + updateSettings: (data: Record) => apiClient.put('/auth/me/settings', data).then(r => r.data), + getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data), + listUsers: () => apiClient.get('/auth/users').then(r => r.data), + uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data), + getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data), + updateAppSettings: (data: Record) => apiClient.put('/auth/app-settings', data).then(r => r.data), + validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), + travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data), + 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), +} + +export const tripsApi = { + list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), + create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), + get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), + update: (id: number | string, data: Record) => apiClient.put(`/trips/${id}`, data).then(r => r.data), + delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), + uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), + unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), + getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), + addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), + removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), +} + +export const daysApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), + delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), +} + +export const placesApi = { + list: (tripId: number | string, params?: Record) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data), + get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data), + 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), +} + +export const assignmentsApi = { + list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), + delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), + reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), + move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), + update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), + getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), + setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), + updateTime: (tripId: number | string, id: number, times: Record) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), +} + +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), + 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), +} + +export const tagsApi = { + list: () => apiClient.get('/tags').then(r => r.data), + create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), + update: (id: number, data: Record) => apiClient.put(`/tags/${id}`, data).then(r => r.data), + delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data), +} + +export const categoriesApi = { + list: () => apiClient.get('/categories').then(r => r.data), + create: (data: Record) => apiClient.post('/categories', data).then(r => r.data), + update: (id: number, data: Record) => apiClient.put(`/categories/${id}`, data).then(r => r.data), + delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data), +} + +export const adminApi = { + users: () => apiClient.get('/admin/users').then(r => r.data), + createUser: (data: Record) => apiClient.post('/admin/users', data).then(r => r.data), + updateUser: (id: number, data: Record) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), + deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), + stats: () => apiClient.get('/admin/stats').then(r => r.data), + saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), + getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), + updateOidc: (data: Record) => apiClient.put('/admin/oidc', data).then(r => r.data), + addons: () => apiClient.get('/admin/addons').then(r => r.data), + updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), + checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), + installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), +} + +export const addonsApi = { + enabled: () => apiClient.get('/addons').then(r => r.data), +} + +export const mapsApi = { + search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), + details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data), + placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data), +} + +export const budgetApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), + delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), + 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), +} + +export const filesApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data), + upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data), + delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data), +} + +export const reservationsApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), + 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), +} + +export const weatherApi = { + get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), + getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), +} + +export const settingsApi = { + get: () => apiClient.get('/settings').then(r => r.data), + set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), + setBulk: (settings: Record) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), +} + +export const accommodationsApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), + delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), +} + +export const dayNotesApi = { + list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), + create: (tripId: number | string, dayId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), + update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data), + delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), +} + +export const collabApi = { + getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), + createNote: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), + updateNote: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), + deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), + uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data), + getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), + createPoll: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), + votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), + closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), + deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), + getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), + sendMessage: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), + deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), + reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data), + linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), +} + +export const backupApi = { + list: () => apiClient.get('/backup/list').then(r => r.data), + create: () => apiClient.post('/backup/create').then(r => r.data), + download: async (filename: string): Promise => { + const token = localStorage.getItem('auth_token') + const res = await fetch(`/api/backup/download/${filename}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Download failed') + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + }, + delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data), + restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data), + uploadRestore: (file: File) => { + const form = new FormData() + form.append('backup', file) + return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, + getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data), + setAutoSettings: (settings: Record) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), +} + +export default apiClient diff --git a/client/src/api/websocket.js b/client/src/api/websocket.ts similarity index 65% rename from client/src/api/websocket.js rename to client/src/api/websocket.ts index c0c1871..bde9815 100644 --- a/client/src/api/websocket.js +++ b/client/src/api/websocket.ts @@ -1,45 +1,47 @@ // Singleton WebSocket manager for real-time collaboration -let socket = null -let reconnectTimer = null +type WebSocketListener = (event: Record) => void +type RefetchCallback = (tripId: string) => void + +let socket: WebSocket | null = null +let reconnectTimer: ReturnType | null = null let reconnectDelay = 1000 const MAX_RECONNECT_DELAY = 30000 -const listeners = new Set() -const activeTrips = new Set() -let currentToken = null -let refetchCallback = null -let mySocketId = null +const listeners = new Set() +const activeTrips = new Set() +let currentToken: string | null = null +let refetchCallback: RefetchCallback | null = null +let mySocketId: string | null = null -export function getSocketId() { +export function getSocketId(): string | null { return mySocketId } -export function setRefetchCallback(fn) { +export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } -function getWsUrl(token) { +function getWsUrl(token: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' return `${protocol}://${location.host}/ws?token=${token}` } -function handleMessage(event) { +function handleMessage(event: MessageEvent): void { try { const parsed = JSON.parse(event.data) - // Store our socket ID from welcome message if (parsed.type === 'welcome') { mySocketId = parsed.socketId return } listeners.forEach(fn => { - try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) } + try { fn(parsed) } catch (err: unknown) { console.error('WebSocket listener error:', err) } }) - } catch (err) { + } catch (err: unknown) { console.error('WebSocket message parse error:', err) } } -function scheduleReconnect() { +function scheduleReconnect(): void { if (reconnectTimer) return reconnectTimer = setTimeout(() => { reconnectTimer = null @@ -50,7 +52,7 @@ function scheduleReconnect() { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY) } -function connectInternal(token, isReconnect = false) { +function connectInternal(token: string, _isReconnect = false): void { if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return } @@ -59,20 +61,16 @@ function connectInternal(token, isReconnect = false) { socket = new WebSocket(url) socket.onopen = () => { - // connection established reconnectDelay = 1000 - // Join active trips on any connect (initial or reconnect) if (activeTrips.size > 0) { activeTrips.forEach(tripId => { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'join', tripId })) - // joined trip room } }) - // Refetch trip data for active trips if (refetchCallback) { activeTrips.forEach(tripId => { - try { refetchCallback(tripId) } catch (err) { + try { refetchCallback!(tripId) } catch (err: unknown) { console.error('Failed to refetch trip data on reconnect:', err) } }) @@ -94,7 +92,7 @@ function connectInternal(token, isReconnect = false) { } } -export function connect(token) { +export function connect(token: string): void { currentToken = token reconnectDelay = 1000 if (reconnectTimer) { @@ -104,7 +102,7 @@ export function connect(token) { connectInternal(token, false) } -export function disconnect() { +export function disconnect(): void { currentToken = null if (reconnectTimer) { clearTimeout(reconnectTimer) @@ -112,30 +110,30 @@ export function disconnect() { } activeTrips.clear() if (socket) { - socket.onclose = null // prevent reconnect + socket.onclose = null socket.close() socket = null } } -export function joinTrip(tripId) { +export function joinTrip(tripId: number | string): void { activeTrips.add(String(tripId)) if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) })) } } -export function leaveTrip(tripId) { +export function leaveTrip(tripId: number | string): void { activeTrips.delete(String(tripId)) if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) })) } } -export function addListener(fn) { +export function addListener(fn: WebSocketListener): void { listeners.add(fn) } -export function removeListener(fn) { +export function removeListener(fn: WebSocketListener): void { listeners.delete(fn) } diff --git a/client/src/components/Admin/AddonManager.jsx b/client/src/components/Admin/AddonManager.tsx similarity index 93% rename from client/src/components/Admin/AddonManager.jsx rename to client/src/components/Admin/AddonManager.tsx index 82a82a4..c1e46f1 100644 --- a/client/src/components/Admin/AddonManager.jsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' @@ -9,7 +9,20 @@ const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, } -function AddonIcon({ name, size = 20 }) { +interface Addon { + id: string + name: string + description: string + icon: string + enabled: boolean +} + +interface AddonIconProps { + name: string + size?: number +} + +function AddonIcon({ name, size = 20 }: AddonIconProps) { const Icon = ICON_MAP[name] || Puzzle return } @@ -31,7 +44,7 @@ export default function AddonManager() { try { const data = await adminApi.addons() setAddons(data.addons) - } catch (err) { + } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) } finally { setLoading(false) @@ -46,7 +59,7 @@ export default function AddonManager() { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) window.dispatchEvent(new Event('addons-changed')) toast.success(t('admin.addons.toast.updated')) - } catch (err) { + } catch (err: unknown) { // Rollback setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) @@ -117,7 +130,13 @@ export default function AddonManager() { ) } -function AddonRow({ addon, onToggle, t }) { +interface AddonRowProps { + addon: Addon + onToggle: (addonId: string) => void + t: (key: string) => string +} + +function AddonRow({ addon, onToggle, t }: AddonRowProps) { const isComingSoon = false return (
diff --git a/client/src/components/Admin/BackupPanel.jsx b/client/src/components/Admin/BackupPanel.tsx similarity index 98% rename from client/src/components/Admin/BackupPanel.jsx rename to client/src/components/Admin/BackupPanel.tsx index 84ec57a..89af898 100644 --- a/client/src/components/Admin/BackupPanel.jsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' 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 { getApiErrorMessage } from '../../types' const INTERVAL_OPTIONS = [ { value: 'hourly', labelKey: 'backup.interval.hourly' }, @@ -73,7 +74,7 @@ export default function BackupPanel() { } const handleUploadRestore = (e) => { - const file = e.target.files?.[0] + const file = (e.target as HTMLInputElement).files?.[0] if (!file) return e.target.value = '' setRestoreConfirm({ type: 'upload', filename: file.name, file }) @@ -90,8 +91,8 @@ export default function BackupPanel() { await backupApi.restore(filename) toast.success(t('backup.toast.restored')) setTimeout(() => window.location.reload(), 1500) - } catch (err) { - toast.error(err.response?.data?.error || t('backup.toast.restoreError')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('backup.toast.restoreError'))) setRestoringFile(null) } } else { @@ -100,8 +101,8 @@ export default function BackupPanel() { await backupApi.uploadRestore(file) toast.success(t('backup.toast.restored')) setTimeout(() => window.location.reload(), 1500) - } catch (err) { - toast.error(err.response?.data?.error || t('backup.toast.uploadError')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('backup.toast.uploadError'))) setIsUploading(false) } } diff --git a/client/src/components/Admin/CategoryManager.jsx b/client/src/components/Admin/CategoryManager.tsx similarity index 96% rename from client/src/components/Admin/CategoryManager.jsx rename to client/src/components/Admin/CategoryManager.tsx index 7b0ad89..8857f7d 100644 --- a/client/src/components/Admin/CategoryManager.jsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { categoriesApi } from '../../api/client' import { useToast } from '../shared/Toast' import { Plus, Edit2, Trash2, Pipette } from 'lucide-react' import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' +import { getApiErrorMessage } from '../../types' const PRESET_COLORS = [ '#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316', @@ -31,7 +32,7 @@ export default function CategoryManager() { try { const data = await categoriesApi.list() setCategories(data.categories || []) - } catch (err) { + } catch (err: unknown) { toast.error(t('categories.toast.loadError')) } finally { setIsLoading(false) @@ -71,8 +72,8 @@ export default function CategoryManager() { toast.success(t('categories.toast.created')) } setForm({ name: '', color: '#6366f1', icon: 'MapPin' }) - } catch (err) { - toast.error(err.response?.data?.error || t('categories.toast.saveError')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('categories.toast.saveError'))) } finally { setIsSaving(false) } @@ -84,8 +85,8 @@ export default function CategoryManager() { await categoriesApi.delete(id) setCategories(prev => prev.filter(c => c.id !== id)) toast.success(t('categories.toast.deleted')) - } catch (err) { - toast.error(err.response?.data?.error || t('categories.toast.deleteError')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('categories.toast.deleteError'))) } } diff --git a/client/src/components/Admin/GitHubPanel.jsx b/client/src/components/Admin/GitHubPanel.tsx similarity index 98% rename from client/src/components/Admin/GitHubPanel.jsx rename to client/src/components/Admin/GitHubPanel.tsx index ea24e30..492a0f3 100644 --- a/client/src/components/Admin/GitHubPanel.jsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' @@ -22,8 +22,8 @@ export default function GitHubPanel() { const data = await res.json() setReleases(prev => append ? [...prev, ...data] : data) setHasMore(data.length === PER_PAGE) - } catch (err) { - setError(err.message) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Unknown error') } } diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.tsx similarity index 95% rename from client/src/components/Budget/BudgetPanel.jsx rename to client/src/components/Budget/BudgetPanel.tsx index e5dbb0c..db34077 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -1,10 +1,31 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react' import ReactDOM from 'react-dom' +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 CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' +import type { BudgetItem, BudgetMember } from '../../types' + +interface TripMember { + id: number + username: string + avatar_url?: string | null +} + +interface PieSegment { + label: string + value: number + color: string +} + +interface PerPersonSummaryEntry { + user_id: number + username: string + avatar_url: string | null + total_assigned: number +} // ── Helpers ────────────────────────────────────────────────────────────────── const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] @@ -60,7 +81,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder } // ── Add Item Row ───────────────────────────────────────────────────────────── -function AddItemRow({ onAdd, t }) { +interface AddItemRowProps { + onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void + t: (key: string) => string +} + +function AddItemRow({ onAdd, t }: AddItemRowProps) { const [name, setName] = useState('') const [price, setPrice] = useState('') const [persons, setPersons] = useState('') @@ -113,7 +139,13 @@ function AddItemRow({ onAdd, t }) { } // ── Chip with custom tooltip ───────────────────────────────────────────────── -function ChipWithTooltip({ label, avatarUrl, size = 20 }) { +interface ChipWithTooltipProps { + label: string + avatarUrl: string | null + size?: number +} + +function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) @@ -156,7 +188,14 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }) { } // ── Budget Member Chips (for Persons column) ──────────────────────────────── -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) { +interface BudgetMemberChipsProps { + members?: BudgetMember[] + tripMembers?: TripMember[] + onSetMembers: (memberIds: number[]) => void + compact?: boolean +} + +function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, 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) @@ -246,7 +285,14 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa } // ── Per-Person Inline (inside total card) ──────────────────────────────────── -function PerPersonInline({ tripId, budgetItems, currency, locale }) { +interface PerPersonInlineProps { + tripId: number + budgetItems: BudgetItem[] + currency: string + locale: string +} + +function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) { const [data, setData] = useState(null) const fmt = (v) => fmtNum(v, locale, currency) @@ -279,7 +325,13 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }) { } // ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── -function PieChart({ segments, size = 200, totalLabel }) { +interface PieChartProps { + segments: PieSegment[] + size?: number + totalLabel: string +} + +function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { if (!segments.length) return null const total = segments.reduce((s, x) => s + x.value, 0) @@ -316,7 +368,12 @@ function PieChart({ segments, size = 200, totalLabel }) { } // ── Main Component ─────────────────────────────────────────────────────────── -export default function BudgetPanel({ tripId, tripMembers = [] }) { +interface BudgetPanelProps { + tripId: number + tripMembers?: TripMember[] +} + +export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore() const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') @@ -355,12 +412,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) { const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} } const handleDeleteCategory = async (cat) => { const items = grouped[cat] || [] - for (const item of items) await deleteBudgetItem(tripId, item.id) + for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } const handleRenameCategory = async (oldName, newName) => { if (!newName.trim() || newName.trim() === oldName) return const items = grouped[oldName] || [] - for (const item of items) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) + for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } const handleAddCategory = () => { if (!newCategoryName.trim()) return diff --git a/client/src/components/Collab/CollabChat.jsx b/client/src/components/Collab/CollabChat.tsx similarity index 96% rename from client/src/components/Collab/CollabChat.jsx rename to client/src/components/Collab/CollabChat.tsx index 45c2efd..9528547 100644 --- a/client/src/components/Collab/CollabChat.jsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -5,6 +5,25 @@ import { collabApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' +import type { User } from '../../types' + +interface ChatReaction { + emoji: string + count: number + users: { id: number; username: string }[] +} + +interface ChatMessage { + id: number + trip_id: number + user_id: number + text: string + reply_to_id: number | null + reactions: ChatReaction[] + created_at: string + user?: { username: string; avatar_url: string | null } + reply_to?: ChatMessage | null +} // ── Twemoji helper (Apple-style emojis via CDN) ── function emojiToCodepoint(emoji) { @@ -75,7 +94,14 @@ function shouldShowDateSeparator(msg, prevMsg) { } /* ── Emoji Picker ── */ -function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) { +interface EmojiPickerProps { + onSelect: (emoji: string) => void + onClose: () => void + anchorRef: React.RefObject + containerRef: React.RefObject +} + +function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) const ref = useRef(null) @@ -142,7 +168,14 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) { /* ── Reaction Quick Menu (right-click) ── */ const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] -function ReactionMenu({ x, y, onReact, onClose }) { +interface ReactionMenuProps { + x: number + y: number + onReact: (emoji: string) => void + onClose: () => void +} + +function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { const ref = useRef(null) useEffect(() => { @@ -179,7 +212,11 @@ function ReactionMenu({ x, y, onReact, onClose }) { } /* ── Message Text with clickable URLs ── */ -function MessageText({ text }) { +interface MessageTextProps { + text: string +} + +function MessageText({ text }: MessageTextProps) { const parts = text.split(URL_REGEX) const urls = text.match(URL_REGEX) || [] const result = [] @@ -198,7 +235,14 @@ function MessageText({ text }) { const URL_REGEX = /https?:\/\/[^\s<>"']+/g const previewCache = {} -function LinkPreview({ url, tripId, own, onLoad }) { +interface LinkPreviewProps { + url: string + tripId: number + own: boolean + onLoad: (() => void) | undefined +} + +function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { const [data, setData] = useState(previewCache[url] || null) const [loading, setLoading] = useState(!previewCache[url]) @@ -252,7 +296,13 @@ function LinkPreview({ url, tripId, own, onLoad }) { } /* ── Reaction Badge with NOMAD tooltip ── */ -function ReactionBadge({ reaction, currentUserId, onReact }) { +interface ReactionBadgeProps { + reaction: ChatReaction + currentUserId: number + onReact: () => void +} + +function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) @@ -295,7 +345,12 @@ function ReactionBadge({ reaction, currentUserId, onReact }) { } /* ── Main Component ── */ -export default function CollabChat({ tripId, currentUser }) { +interface CollabChatProps { + tripId: number + currentUser: User +} + +export default function CollabChat({ tripId, currentUser }: CollabChatProps) { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' diff --git a/client/src/components/Collab/CollabNotes.jsx b/client/src/components/Collab/CollabNotes.tsx similarity index 94% rename from client/src/components/Collab/CollabNotes.jsx rename to client/src/components/Collab/CollabNotes.tsx index 8423a8c..ea78eb1 100644 --- a/client/src/components/Collab/CollabNotes.jsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -1,16 +1,56 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import DOM from 'react-dom' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' +import type { User } from '../../types' + +interface NoteFile { + id: number + filename: string + original_name: string + mime_type: string + url?: string +} + +interface CollabNote { + id: number + trip_id: number + title: string + content: string + category: string + website: string | null + pinned: boolean + color: string | null + username: string + avatar_url: string | null + avatar: string | null + user_id: number + created_at: string + author?: { username: string; avatar: string | null } + user?: { username: string; avatar: string | null } + files?: NoteFile[] +} + +interface NoteAuthor { + username: string + avatar?: string | null +} const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" // ── Website Thumbnail (fetches OG image) ──────────────────────────────────── const ogCache = {} -function WebsiteThumbnail({ url, tripId, color }) { +interface WebsiteThumbnailProps { + url: string + tripId: number + color: string +} + +function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) { const [data, setData] = useState(ogCache[url] || null) const [failed, setFailed] = useState(false) @@ -46,7 +86,12 @@ function WebsiteThumbnail({ url, tripId, color }) { } // ── File Preview Portal ───────────────────────────────────────────────────── -function FilePreviewPortal({ file, onClose }) { +interface FilePreviewPortalProps { + file: NoteFile | null + onClose: () => void +} + +function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { if (!file) return null const url = file.url || `/uploads/${file.filename}` const isImage = file.mime_type?.startsWith('image/') @@ -120,7 +165,12 @@ const formatTimestamp = (ts, t, locale) => { } // ── Avatar ────────────────────────────────────────────────────────────────── -function UserAvatar({ user, size = 14 }) { +interface UserAvatarProps { + user: NoteAuthor | null + size?: number +} + +function UserAvatar({ user, size = 14 }: UserAvatarProps) { if (!user) return null if (user.avatar) { return ( @@ -161,7 +211,19 @@ function UserAvatar({ user, size = 14 }) { } // ── New Note Modal (portal to body) ───────────────────────────────────────── -function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }) { +interface NoteFormModalProps { + onClose: () => void + onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise + onDeleteFile: (noteId: number, fileId: number) => Promise + existingCategories: string[] + categoryColors: Record + getCategoryColor: (category: string) => string + note: CollabNote | null + tripId: number + t: (key: string) => string +} + +function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) { const isEdit = !!note const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean) @@ -236,7 +298,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca onPaste={e => { const items = e.clipboardData?.items if (!items) return - for (const item of items) { + for (const item of Array.from(items)) { if (item.type.startsWith('image/') || item.type === 'application/pdf') { e.preventDefault() const file = item.getAsFile() @@ -390,7 +452,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{t('collab.notes.attachFiles')}
- { setPendingFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = '' }} /> + { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
{/* Existing attachments (edit mode) */} {existingAttachments.map(a => { @@ -448,7 +510,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca ) } -function EditableCatName({ name, onRename }) { +interface EditableCatNameProps { + name: string + onRename: (newName: string) => void +} + +function EditableCatName({ name, onRename }: EditableCatNameProps) { const [editing, setEditing] = useState(false) const [value, setValue] = useState(name) const inputRef = useRef(null) @@ -477,7 +544,16 @@ function EditableCatName({ name, onRename }) { } // ── Category Settings Modal ────────────────────────────────────────────────── -function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }) { +interface CategorySettingsModalProps { + onClose: () => void + categories: string[] + categoryColors: Record + onSave: (colors: Record) => void + onRenameCategory: (oldName: string, newName: string) => Promise + t: (key: string) => string +} + +function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) { const [localColors, setLocalColors] = useState({ ...categoryColors }) const [renames, setRenames] = useState({}) // { oldName: newName } const [newCatName, setNewCatName] = useState('') @@ -608,7 +684,19 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on } // ── Note Card ─────────────────────────────────────────────────────────────── -function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }) { +interface NoteCardProps { + note: CollabNote + currentUser: User + onUpdate: (noteId: number, data: Partial) => Promise + onDelete: (noteId: number) => Promise + onEdit: (note: CollabNote) => void + onPreviewFile: (file: NoteFile) => void + getCategoryColor: (category: string) => string + tripId: number + t: (key: string) => string +} + +function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) { const [hovered, setHovered] = useState(false) const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) } @@ -773,7 +861,12 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile } // ── Main Component ────────────────────────────────────────────────────────── -export default function CollabNotes({ tripId, currentUser }) { +interface CollabNotesProps { + tripId: number + currentUser: User +} + +export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) { const { t } = useTranslation() const [notes, setNotes] = useState([]) const [loading, setLoading] = useState(true) diff --git a/client/src/components/Collab/CollabPanel.jsx b/client/src/components/Collab/CollabPanel.tsx similarity index 94% rename from client/src/components/Collab/CollabPanel.jsx rename to client/src/components/Collab/CollabPanel.tsx index c640c17..e67dd82 100644 --- a/client/src/components/Collab/CollabPanel.jsx +++ b/client/src/components/Collab/CollabPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react' @@ -23,7 +23,18 @@ const card = { overflow: 'hidden', minHeight: 0, } -export default function CollabPanel({ tripId, tripMembers = [] }) { +interface TripMember { + id: number + username: string + avatar_url?: string | null +} + +interface CollabPanelProps { + tripId: number + tripMembers?: TripMember[] +} + +export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) { const { user } = useAuthStore() const { t } = useTranslation() const [mobileTab, setMobileTab] = useState('chat') diff --git a/client/src/components/Collab/CollabPolls.jsx b/client/src/components/Collab/CollabPolls.tsx similarity index 94% rename from client/src/components/Collab/CollabPolls.jsx rename to client/src/components/Collab/CollabPolls.tsx index fff4d72..66083ef 100644 --- a/client/src/components/Collab/CollabPolls.jsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -4,6 +4,30 @@ import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import ReactDOM from 'react-dom' +import type { User } from '../../types' + +interface PollVoter { + user_id: number + username: string + avatar_url: string | null +} + +interface PollOption { + id: number + text: string + voters: PollVoter[] +} + +interface Poll { + id: number + question: string + options: PollOption[] + multi_choice: boolean + is_closed: boolean + deadline: string | null + created_by: number + created_at: string +} const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" @@ -29,7 +53,13 @@ function totalVotes(poll) { } // ── Create Poll Modal ──────────────────────────────────────────────────────── -function CreatePollModal({ onClose, onCreate, t }) { +interface CreatePollModalProps { + onClose: () => void + onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise + t: (key: string) => string +} + +function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { const [question, setQuestion] = useState('') const [options, setOptions] = useState(['', '']) const [multiChoice, setMultiChoice] = useState(false) @@ -111,7 +141,12 @@ function CreatePollModal({ onClose, onCreate, t }) { } // ── Voter Chip with custom tooltip ──────────────────────────────────────────── -function VoterChip({ voter, offset }) { +interface VoterChipProps { + voter: PollVoter + offset: boolean +} + +function VoterChip({ voter, offset }: VoterChipProps) { const [hover, setHover] = useState(false) const ref = React.useRef(null) const [pos, setPos] = useState({ top: 0, left: 0 }) @@ -152,7 +187,16 @@ function VoterChip({ voter, offset }) { } // ── Poll Card ──────────────────────────────────────────────────────────────── -function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) { +interface PollCardProps { + poll: Poll + currentUser: User + onVote: (pollId: number, optionId: number) => Promise + onClose: (pollId: number) => Promise + onDelete: (pollId: number) => Promise + t: (key: string) => string +} + +function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) { const total = totalVotes(poll) const isClosed = poll.is_closed || isExpired(poll.deadline) const remaining = timeRemaining(poll.deadline) @@ -286,7 +330,12 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) { } // ── Main Component ─────────────────────────────────────────────────────────── -export default function CollabPolls({ tripId, currentUser }) { +interface CollabPollsProps { + tripId: number + currentUser: User +} + +export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) { const { t } = useTranslation() const [polls, setPolls] = useState([]) const [loading, setLoading] = useState(true) diff --git a/client/src/components/Collab/WhatsNextWidget.jsx b/client/src/components/Collab/WhatsNextWidget.tsx similarity index 97% rename from client/src/components/Collab/WhatsNextWidget.jsx rename to client/src/components/Collab/WhatsNextWidget.tsx index 411483d..6ad678d 100644 --- a/client/src/components/Collab/WhatsNextWidget.jsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -26,7 +26,17 @@ function formatDayLabel(date, t, locale) { return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' }) } -export default function WhatsNextWidget({ tripMembers = [] }) { +interface TripMember { + id: number + username: string + avatar_url?: string | null +} + +interface WhatsNextWidgetProps { + tripMembers?: TripMember[] +} + +export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) { const { days, assignments } = useTripStore() const { t, locale } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' diff --git a/client/src/components/Dashboard/CurrencyWidget.jsx b/client/src/components/Dashboard/CurrencyWidget.tsx similarity index 98% rename from client/src/components/Dashboard/CurrencyWidget.jsx rename to client/src/components/Dashboard/CurrencyWidget.tsx index 0b3f244..a9a648d 100644 --- a/client/src/components/Dashboard/CurrencyWidget.jsx +++ b/client/src/components/Dashboard/CurrencyWidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import { ArrowRightLeft, RefreshCw } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomSelect from '../shared/CustomSelect' diff --git a/client/src/components/Dashboard/TimezoneWidget.jsx b/client/src/components/Dashboard/TimezoneWidget.tsx similarity index 99% rename from client/src/components/Dashboard/TimezoneWidget.jsx rename to client/src/components/Dashboard/TimezoneWidget.tsx index b1694c4..70ffff4 100644 --- a/client/src/components/Dashboard/TimezoneWidget.jsx +++ b/client/src/components/Dashboard/TimezoneWidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { Clock, Plus, X } from 'lucide-react' import { useTranslation } from '../../i18n' diff --git a/client/src/components/Dashboard/TravelStats.jsx b/client/src/components/Dashboard/TravelStats.jsx deleted file mode 100644 index 4a7845e..0000000 --- a/client/src/components/Dashboard/TravelStats.jsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react' -import { Globe, MapPin, Plane } from 'lucide-react' -import { authApi } from '../../api/client' -import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' - -// Numeric ISO → country name lookup (countries-110m uses numeric IDs) -const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"} - -// Our country names from addresses → match against GeoJSON names -function isCountryMatch(geoName, visitedCountries) { - if (!geoName) return false - const lower = geoName.toLowerCase() - return visitedCountries.some(c => { - const cl = c.toLowerCase() - return lower === cl || lower.includes(cl) || cl.includes(lower) - // Handle common mismatches - || (cl === 'usa' && lower.includes('united states')) - || (cl === 'uk' && lower === 'united kingdom') - || (cl === 'south korea' && lower === 'korea' || lower === 'south korea') - || (cl === 'deutschland' && lower === 'germany') - || (cl === 'frankreich' && lower === 'france') - || (cl === 'italien' && lower === 'italy') - || (cl === 'spanien' && lower === 'spain') - || (cl === 'österreich' && lower === 'austria') - || (cl === 'schweiz' && lower === 'switzerland') - || (cl === 'niederlande' && lower === 'netherlands') - || (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye')) - || (cl === 'griechenland' && lower === 'greece') - || (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia')) - || (cl === 'ägypten' && lower === 'egypt') - || (cl === 'südkorea' && lower.includes('korea')) - || (cl === 'indien' && lower === 'india') - || (cl === 'brasilien' && lower === 'brazil') - || (cl === 'argentinien' && lower === 'argentina') - || (cl === 'russland' && lower === 'russia') - || (cl === 'australien' && lower === 'australia') - || (cl === 'kanada' && lower === 'canada') - || (cl === 'mexiko' && lower === 'mexico') - || (cl === 'neuseeland' && lower === 'new zealand') - || (cl === 'singapur' && lower === 'singapore') - || (cl === 'kroatien' && lower === 'croatia') - || (cl === 'ungarn' && lower === 'hungary') - || (cl === 'rumänien' && lower === 'romania') - || (cl === 'polen' && lower === 'poland') - || (cl === 'schweden' && lower === 'sweden') - || (cl === 'norwegen' && lower === 'norway') - || (cl === 'dänemark' && lower === 'denmark') - || (cl === 'finnland' && lower === 'finland') - || (cl === 'irland' && lower === 'ireland') - || (cl === 'portugal' && lower === 'portugal') - || (cl === 'belgien' && lower === 'belgium') - }) -} - -const TOTAL_COUNTRIES = 195 - -// Simple Mercator projection for SVG -function project(lon, lat, width, height) { - const clampedLat = Math.max(-75, Math.min(83, lat)) - const x = ((lon + 180) / 360) * width - const latRad = (clampedLat * Math.PI) / 180 - const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2)) - const y = (height / 2) - (width * mercN) / (2 * Math.PI) - return [x, y] -} - -function geoToPath(coords, width, height) { - return coords.map((ring) => { - // Split ring at dateline crossings to avoid horizontal stripes - const segments = [[]] - for (let i = 0; i < ring.length; i++) { - const [lon, lat] = ring[i] - if (i > 0) { - const prevLon = ring[i - 1][0] - if (Math.abs(lon - prevLon) > 180) { - // Dateline crossing — start new segment - segments.push([]) - } - } - const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height) - segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`) - } - return segments - .filter(s => s.length > 2) - .map(s => 'M' + s.join('L') + 'Z') - .join(' ') - }).join(' ') -} - -let geoJsonCache = null -async function loadGeoJson() { - if (geoJsonCache) return geoJsonCache - try { - const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json') - const topo = await res.json() - const { feature } = await import('topojson-client') - const geo = feature(topo, topo.objects.countries) - geo.features.forEach(f => { - f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || '' - }) - geoJsonCache = geo - return geo - } catch { return null } -} - -export default function TravelStats() { - const { t } = useTranslation() - const dm = useSettingsStore(s => s.settings.dark_mode) - const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const [stats, setStats] = useState(null) - const [geoData, setGeoData] = useState(null) - - useEffect(() => { - authApi.travelStats().then(setStats).catch(() => {}) - loadGeoJson().then(setGeoData) - }, []) - - const countryCount = stats?.countries?.length || 0 - const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1) - - if (!stats || stats.totalPlaces === 0) return null - - return ( -
- {/* Stats Card */} -
- {/* Progress bar */} -
-
- {t('stats.worldProgress')} - {worldPercent}% -
-
-
-
-
- {countryCount} {t('stats.visited')} - {TOTAL_COUNTRIES - countryCount} {t('stats.remaining')} -
-
- - {/* Stat grid */} -
- - - - -
- - {/* Country tags */} - {stats.countries.length > 0 && ( - <> -
{t('stats.visitedCountries')}
-
- {stats.countries.map(c => ( - {c} - ))} -
- - )} -
-
- ) -} - -function StatBox({ icon: Icon, value, label }) { - return ( -
- -
-
{value}
-
{label}
-
-
- ) -} diff --git a/client/src/components/Files/FileManager.jsx b/client/src/components/Files/FileManager.tsx similarity index 95% rename from client/src/components/Files/FileManager.jsx rename to client/src/components/Files/FileManager.tsx index e8c28d0..a30f26c 100644 --- a/client/src/components/Files/FileManager.jsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,9 +1,11 @@ -import React, { useState, useCallback } from 'react' import ReactDOM from 'react-dom' +import { useState, useCallback } from 'react' +import DOM from 'react-dom' import { useDropzone } from 'react-dropzone' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' +import type { Place, Reservation, TripFile } from '../../types' function isImage(mimeType) { if (!mimeType) return false @@ -32,7 +34,12 @@ function formatDateWithLocale(dateStr, locale) { } // Image lightbox -function ImageLightbox({ file, onClose }) { +interface ImageLightboxProps { + file: TripFile & { url: string } + onClose: () => void +} + +function ImageLightbox({ file, onClose }: ImageLightboxProps) { const { t } = useTranslation() return (
+ label: string +} + +function SourceBadge({ icon: Icon, label }: SourceBadgeProps) { return ( Promise + onDelete: (fileId: number) => Promise + onUpdate: (fileId: number, data: Partial) => Promise + places: Place[] + reservations?: Reservation[] + tripId: number + allowedFileTypes: Record +} + +export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) const [filterType, setFilterType] = useState('all') const [lightboxFile, setLightboxFile] = useState(null) @@ -112,7 +135,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, const items = e.clipboardData?.items if (!items) return const files = [] - for (const item of items) { + for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile() if (file) files.push(file) diff --git a/client/src/components/Layout/DemoBanner.jsx b/client/src/components/Layout/DemoBanner.tsx similarity index 93% rename from client/src/components/Layout/DemoBanner.jsx rename to client/src/components/Layout/DemoBanner.tsx index 4c5983b..fcb49a7 100644 --- a/client/src/components/Layout/DemoBanner.jsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -2,7 +2,26 @@ import React, { useState, useEffect } from 'react' import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react' import { useTranslation } from '../../i18n' -const texts = { +interface DemoTexts { + titleBefore: string + titleAfter: string + title: string + description: string + resetIn: string + minutes: string + uploadNote: string + fullVersionTitle: string + features: string[] + addonsTitle: string + addons: [string, string][] + whatIs: string + whatIsDesc: string + selfHost: string + selfHostLink: string + close: string +} + +const texts: Record = { de: { titleBefore: 'Willkommen bei ', titleAfter: '', @@ -72,9 +91,9 @@ const texts = { const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield] const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft] -export default function DemoBanner() { - const [dismissed, setDismissed] = useState(false) - const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes()) +export default function DemoBanner(): React.ReactElement | null { + const [dismissed, setDismissed] = useState(false) + const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes()) const { language } = useTranslation() const t = texts[language] || texts.en @@ -98,7 +117,7 @@ export default function DemoBanner() { maxWidth: 480, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', maxHeight: '90vh', overflow: 'auto', - }} onClick={e => e.stopPropagation()}> + }} onClick={(e: React.MouseEvent) => e.stopPropagation()}> {/* Header */}
diff --git a/client/src/components/Layout/Navbar.jsx b/client/src/components/Layout/Navbar.tsx similarity index 95% rename from client/src/components/Layout/Navbar.jsx rename to client/src/components/Layout/Navbar.tsx index 5e6e2b0..c07d159 100644 --- a/client/src/components/Layout/Navbar.jsx +++ b/client/src/components/Layout/Navbar.tsx @@ -6,18 +6,34 @@ import { useSettingsStore } from '../../store/settingsStore' 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' -const ADDON_ICONS = { CalendarDays, Briefcase, Globe } +const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe } -export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) { +interface NavbarProps { + tripTitle?: string + tripId?: string + onBack?: () => void + showBack?: boolean + onShare?: () => void +} + +interface Addon { + id: string + name: string + icon: string + type: string +} + +export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { const { user, logout } = useAuthStore() const { settings, updateSetting } = useSettingsStore() 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 [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) diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.tsx similarity index 92% rename from client/src/components/Map/MapView.jsx rename to client/src/components/Map/MapView.tsx index 1bac23e..6743965 100644 --- a/client/src/components/Map/MapView.jsx +++ b/client/src/components/Map/MapView.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react' -import ReactDOM from 'react-dom' +import { useEffect, useRef, useState, useMemo } from 'react' +import DOM from 'react-dom' import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' @@ -7,6 +7,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' import { getCategoryIcon } from '../shared/categoryIcons' +import type { Place } from '../../types' // Fix default marker icons for vite delete L.Icon.Default.prototype._getIconUrl @@ -93,7 +94,14 @@ function createPlaceIcon(place, orderNumbers, isSelected) { }) } -function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) { +interface SelectionControllerProps { + places: Place[] + selectedPlaceId: number | null + dayPlaces: Place[] + paddingOpts: Record +} + +function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) { const map = useMap() const prev = useRef(null) @@ -117,7 +125,12 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts } return null } -function MapController({ center, zoom }) { +interface MapControllerProps { + center: [number, number] + zoom: number +} + +function MapController({ center, zoom }: MapControllerProps) { const map = useMap() const prevCenter = useRef(center) @@ -132,7 +145,13 @@ function MapController({ center, zoom }) { } // Fit bounds when places change (fitKey triggers re-fit) -function BoundsController({ places, fitKey, paddingOpts }) { +interface BoundsControllerProps { + places: Place[] + fitKey: number + paddingOpts: Record +} + +function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) @@ -149,7 +168,11 @@ function BoundsController({ places, fitKey, paddingOpts }) { return null } -function MapClickHandler({ onClick }) { +interface MapClickHandlerProps { + onClick: ((e: L.LeafletMouseEvent) => void) | null +} + +function MapClickHandler({ onClick }: MapClickHandlerProps) { const map = useMap() useEffect(() => { if (!onClick) return @@ -160,7 +183,13 @@ function MapClickHandler({ onClick }) { } // ── Route travel time label ── -function RouteLabel({ midpoint, walkingText, drivingText }) { +interface RouteLabelProps { + midpoint: [number, number] + walkingText: string + drivingText: string +} + +function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { const map = useMap() const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false) diff --git a/client/src/components/Map/RouteCalculator.js b/client/src/components/Map/RouteCalculator.ts similarity index 55% rename from client/src/components/Map/RouteCalculator.js rename to client/src/components/Map/RouteCalculator.ts index 5afb1bf..6f79a97 100644 --- a/client/src/components/Map/RouteCalculator.js +++ b/client/src/components/Map/RouteCalculator.ts @@ -1,19 +1,18 @@ -// OSRM routing utility - free, no API key required +import type { RouteResult, RouteSegment, Waypoint } from '../../types' + const OSRM_BASE = 'https://router.project-osrm.org/route/v1' -/** - * Calculate a route between multiple waypoints using OSRM - * @param {Array<{lat: number, lng: number}>} waypoints - * @param {string} profile - 'driving' | 'walking' | 'cycling' - * @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>} - */ -export async function calculateRoute(waypoints, profile = 'driving', { signal } = {}) { +/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ +export async function calculateRoute( + waypoints: Waypoint[], + profile: 'driving' | 'walking' | 'cycling' = 'driving', + { signal }: { signal?: AbortSignal } = {} +): Promise { if (!waypoints || waypoints.length < 2) { throw new Error('At least 2 waypoints required') } - const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';') - // OSRM public API only supports driving; we override duration for other modes + const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false` const response = await fetch(url, { signal }) @@ -28,21 +27,20 @@ export async function calculateRoute(waypoints, profile = 'driving', { signal } } const route = data.routes[0] - const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng]) + const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng]) - const distance = route.distance // meters - // Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h) - let duration + const distance: number = route.distance + let duration: number if (profile === 'walking') { duration = distance / (5000 / 3600) } else if (profile === 'cycling') { duration = distance / (15000 / 3600) } else { - duration = route.duration // driving: use OSRM value + duration = route.duration } - const walkingDuration = distance / (5000 / 3600) // 5 km/h - const drivingDuration = route.duration // OSRM driving value + const walkingDuration = distance / (5000 / 3600) + const drivingDuration: number = route.duration return { coordinates, @@ -55,29 +53,23 @@ export async function calculateRoute(waypoints, profile = 'driving', { signal } } } -/** - * Generate a Google Maps directions URL for the given places - */ -export function generateGoogleMapsUrl(places) { - const valid = places.filter(p => p.lat && p.lng) +export function generateGoogleMapsUrl(places: Waypoint[]): string | null { + const valid = places.filter((p) => p.lat && p.lng) if (valid.length === 0) return null if (valid.length === 1) { return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}` } - // Use /dir/stop1/stop2/.../stopN format — all stops as path segments - const stops = valid.map(p => `${p.lat},${p.lng}`).join('/') + const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/') return `https://www.google.com/maps/dir/${stops}` } -/** - * Simple nearest-neighbor route optimization - */ -export function optimizeRoute(places) { - const valid = places.filter(p => p.lat && p.lng) +/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */ +export function optimizeRoute(places: Waypoint[]): Waypoint[] { + const valid = places.filter((p) => p.lat && p.lng) if (valid.length <= 2) return places - const visited = new Set() - const result = [] + const visited = new Set() + const result: Waypoint[] = [] let current = valid[0] visited.add(0) result.push(current) @@ -100,14 +92,14 @@ export function optimizeRoute(places) { return result } -/** - * Calculate per-leg travel times in a single OSRM request - * Returns array of { mid, walkingText, drivingText } for each leg - */ -export async function calculateSegments(waypoints, { signal } = {}) { +/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */ +export async function calculateSegments( + waypoints: Waypoint[], + { signal }: { signal?: AbortSignal } = {} +): Promise { if (!waypoints || waypoints.length < 2) return [] - const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';') + const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration` const response = await fetch(url, { signal }) @@ -117,11 +109,11 @@ export async function calculateSegments(waypoints, { signal } = {}) { if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') const legs = data.routes[0].legs - return legs.map((leg, i) => { - const from = [waypoints[i].lat, waypoints[i].lng] - const to = [waypoints[i + 1].lat, waypoints[i + 1].lng] - const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] - const walkingDuration = leg.distance / (5000 / 3600) // 5 km/h + return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => { + const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] + const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] + const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] + const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, walkingText: formatDuration(walkingDuration), @@ -130,14 +122,14 @@ export async function calculateSegments(waypoints, { signal } = {}) { }) } -function formatDistance(meters) { +function formatDistance(meters: number): string { if (meters < 1000) { return `${Math.round(meters)} m` } return `${(meters / 1000).toFixed(1)} km` } -function formatDuration(seconds) { +function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) if (h > 0) { diff --git a/client/src/components/PDF/TripPDF.jsx b/client/src/components/PDF/TripPDF.tsx similarity index 97% rename from client/src/components/PDF/TripPDF.jsx rename to client/src/components/PDF/TripPDF.tsx index 1df45b5..abaf03f 100644 --- a/client/src/components/PDF/TripPDF.jsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -3,6 +3,7 @@ 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 type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' 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) { @@ -88,7 +89,18 @@ async function fetchPlacePhotos(assignments) { return photoMap } -export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) { +interface downloadTripPDFProps { + trip: Trip + days: Day[] + places: Place[] + assignments: AssignmentsMap + categories: Category[] + dayNotes: DayNotesMap + t: (key: string, params?: Record) => string + locale: string +} + +export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) { await ensureRenderer() const loc = _locale || 'de-DE' const tr = _t || (k => k) diff --git a/client/src/components/Packing/PackingListPanel.jsx b/client/src/components/Packing/PackingListPanel.tsx similarity index 96% rename from client/src/components/Packing/PackingListPanel.jsx rename to client/src/components/Packing/PackingListPanel.tsx index 863baa8..7a45653 100644 --- a/client/src/components/Packing/PackingListPanel.jsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef } from 'react' +import { useState, useMemo, useRef } from 'react' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' @@ -6,6 +6,7 @@ import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, } from 'lucide-react' +import type { PackingItem } from '../../types' const VORSCHLAEGE = [ { name: 'Passport', category: 'Documents' }, @@ -64,7 +65,14 @@ function katColor(kat, allCategories) { } // ── Artikel-Zeile ────────────────────────────────────────────────────────── -function ArtikelZeile({ item, tripId, categories, onCategoryChange }) { +interface ArtikelZeileProps { + item: PackingItem + tripId: number + categories: string[] + onCategoryChange: () => void +} + +function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) { const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(item.name) const [hovered, setHovered] = useState(false) @@ -178,7 +186,16 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) { } // ── Kategorie-Gruppe ─────────────────────────────────────────────────────── -function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) { +interface KategorieGruppeProps { + kategorie: string + items: PackingItem[] + tripId: number + allCategories: string[] + onRename: (oldName: string, newName: string) => Promise + onDeleteAll: (items: PackingItem[]) => Promise +} + +function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) { const [offen, setOffen] = useState(true) const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) @@ -198,12 +215,12 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on } const handleCheckAll = async () => { - for (const item of items) { + for (const item of Array.from(items)) { if (!item.checked) await togglePackingItem(tripId, item.id, true) } } const handleUncheckAll = async () => { - for (const item of items) { + for (const item of Array.from(items)) { if (item.checked) await togglePackingItem(tripId, item.id, false) } } @@ -272,7 +289,14 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on ) } -function MenuItem({ icon, label, onClick, danger }) { +interface MenuItemProps { + icon: React.ReactNode + label: string + onClick: () => void + danger: boolean +} + +function MenuItem({ icon, label, onClick, danger }: MenuItemProps) { return ( - -
- } - > -
- {error && ( -
- {error} -
- )} - - {/* Place search — Google Maps or OpenStreetMap fallback */} -
- {!hasMapsKey && ( -

- {t('places.osmActive')} -

- )} -
-
- - setMapQuery(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleMapSearch()} - placeholder={t('places.mapsSearchPlaceholder')} - className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white" - /> -
- -
- - {mapResults.length > 0 && ( -
- {mapResults.map((p, i) => ( - - ))} -
- )} -
- - {/* Name */} -
- - update('name', e.target.value)} - required - placeholder="e.g. Eiffel Tower" - className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -
- - {/* Description */} -
- -