refactoring: TypeScript migration, security fixes,

This commit is contained in:
Maurice
2026-03-27 18:40:18 +01:00
parent 510475a46f
commit 8396a75223
150 changed files with 8116 additions and 8467 deletions

View File

@@ -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"]

View File

@@ -25,6 +25,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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 <Navigate to="/dashboard" replace />
}
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 <html> + 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])

View File

@@ -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

248
client/src/api/client.ts Normal file
View File

@@ -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<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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<void> => {
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<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export default apiClient

View File

@@ -1,45 +1,47 @@
// Singleton WebSocket manager for real-time collaboration
let socket = null
let reconnectTimer = null
type WebSocketListener = (event: Record<string, unknown>) => void
type RefetchCallback = (tripId: string) => void
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | 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<WebSocketListener>()
const activeTrips = new Set<string>()
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)
}

View File

@@ -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 <Icon size={size} />
}
@@ -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 (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>

View File

@@ -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)
}
}

View File

@@ -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')))
}
}

View File

@@ -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')
}
}

View File

@@ -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

View File

@@ -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<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
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'

View File

@@ -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<void>
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
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
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = '' }} />
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* 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<string, string>
onSave: (colors: Record<string, string>) => void
onRenameCategory: (oldName: string, newName: string) => Promise<void>
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<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
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)

View File

@@ -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')

View File

@@ -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<void>
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<void>
onClose: (pollId: number) => Promise<void>
onDelete: (pollId: number) => Promise<void>
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)

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 (
<div style={{ width: 340 }}>
{/* Stats Card */}
<div style={{
borderRadius: 20, overflow: 'hidden', height: 300,
display: 'flex', flexDirection: 'column', justifyContent: 'center',
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
padding: 16,
}}>
{/* Progress bar */}
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
width: `${Math.max(1, parseFloat(worldPercent))}%`,
transition: 'width 0.5s ease',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
</div>
</div>
{/* Stat grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
</div>
{/* Country tags */}
{stats.countries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{stats.countries.map(c => (
<span key={c} style={{
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
}}>{c}</span>
))}
</div>
</>
)}
</div>
</div>
)
}
function StatBox({ icon: Icon, value, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 10, background: 'var(--bg-hover)',
}}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
</div>
</div>
)
}

View File

@@ -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 (
<div
@@ -62,7 +69,12 @@ function ImageLightbox({ file, onClose }) {
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
}
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
@@ -77,7 +89,18 @@ function SourceBadge({ icon: Icon, label }) {
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[]
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record<string, string[]>
}
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)

View File

@@ -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<string, DemoTexts> = {
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<boolean>(false)
const [minutesLeft, setMinutesLeft] = useState<number>(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<HTMLDivElement>) => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>

View File

@@ -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<string, LucideIcon> = { 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<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)

View File

@@ -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<string, number>
}
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<string, number>
}
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)

View File

@@ -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<RouteResult> {
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<number>()
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<RouteSegment[]> {
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) {

View File

@@ -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, string | number>) => 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)

View File

@@ -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<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
}
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 (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
@@ -289,7 +313,12 @@ function MenuItem({ icon, label, onClick, danger }) {
}
// ── Haupt-Panel ────────────────────────────────────────────────────────────
export default function PackingListPanel({ tripId, items }) {
interface PackingListPanelProps {
tripId: number
items: PackingItem[]
}
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
const [neuerName, setNeuerName] = useState('')
const [neueKategorie, setNeueKategorie] = useState('')
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)

View File

@@ -1,11 +1,22 @@
import React, { useState, useMemo } from 'react'
import { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
interface PhotoGalleryProps {
photos: Photo[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (photoId: number) => Promise<void>
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
places: Place[]
days: Day[]
tripId: number
}
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
@@ -153,7 +164,14 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
)
}
function PhotoThumbnail({ photo, days, places, onClick }) {
interface PhotoThumbnailProps {
photo: Photo
days: Day[]
places: Place[]
onClick: () => void
}
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
@@ -168,8 +186,8 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
(e.target as HTMLImageElement).style.display = 'none'
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
}}
/>

View File

@@ -1,8 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
interface PhotoLightboxProps {
photos: Photo[]
initialIndex: number
onClose: () => void
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
onDelete: (photoId: number) => Promise<void>
days: Day[]
places: Place[]
tripId: number
}
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }: PhotoLightboxProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false)

View File

@@ -1,9 +1,18 @@
import React, { useState, useCallback } from 'react'
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { Place, Day } from '../../types'
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
interface PhotoUploadProps {
tripId: number
days: Day[]
places: Place[]
onUpload: (fd: FormData) => Promise<void>
onClose: () => void
}
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
const { t } = useTranslation()
const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('')
@@ -48,7 +57,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
await onUpload(formData)
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
} catch (err) {
} catch (err: unknown) {
console.error('Upload failed:', err)
} finally {
setUploading(false)

View File

@@ -1,503 +0,0 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { Search, Plus, MapPin, Loader } from 'lucide-react'
const STATUSES = [
{ value: 'none', label: 'None' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
]
export default function PlaceFormModal({
isOpen,
onClose,
onSave,
place,
tripId,
categories: initialCategories = [],
tags: initialTags = [],
onCategoryCreated,
onTagCreated,
}) {
const isEditing = !!place
const { user, hasMapsKey } = useAuthStore()
const { t } = useTranslation()
const toast = useToast()
const [categories, setCategories] = useState(initialCategories)
const [tags, setTags] = useState(initialTags)
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
useEffect(() => { setTags(initialTags) }, [initialTags])
const emptyForm = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
google_place_id: '',
website: '',
tags: [],
}
const [formData, setFormData] = useState(emptyForm)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
// Maps search state
const [mapQuery, setMapQuery] = useState('')
const [mapResults, setMapResults] = useState([])
const [mapSearching, setMapSearching] = useState(false)
// New category/tag
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
const [showNewCategory, setShowNewCategory] = useState(false)
const [newTagName, setNewTagName] = useState('')
const [newTagColor, setNewTagColor] = useState('#374151')
const [showNewTag, setShowNewTag] = useState(false)
useEffect(() => {
if (place && isOpen) {
setFormData({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat ?? '',
lng: place.lng ?? '',
category_id: place.category_id || '',
place_time: place.place_time || '',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
google_place_id: place.google_place_id || '',
website: place.website || '',
tags: (place.tags || []).map(t => t.id),
})
} else if (!place && isOpen) {
setFormData(emptyForm)
}
setError('')
setMapResults([])
setMapQuery('')
}, [place, isOpen])
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const toggleTag = (tagId) => {
setFormData(prev => ({
...prev,
tags: prev.tags.includes(tagId)
? prev.tags.filter(id => id !== tagId)
: [...prev.tags, tagId]
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Place name is required')
return
}
setIsLoading(true)
setError('')
try {
await onSave({
...formData,
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
category_id: formData.category_id || null,
})
onClose()
} catch (err) {
setError(err.message || 'Failed to save place')
} finally {
setIsLoading(false)
}
}
const [searchSource, setSearchSource] = useState(null)
const handleMapSearch = async () => {
if (!mapQuery.trim()) return
setMapSearching(true)
try {
const data = await mapsApi.search(mapQuery)
setMapResults(data.places || [])
setSearchSource(data.source || 'google')
} catch (err) {
toast.error(err.response?.data?.error || t('places.mapsSearchError'))
} finally {
setMapSearching(false)
}
}
const selectMapPlace = (p) => {
setFormData(prev => ({
...prev,
name: p.name || prev.name,
address: p.address || prev.address,
lat: p.lat ?? prev.lat,
lng: p.lng ?? prev.lng,
google_place_id: p.google_place_id || prev.google_place_id,
website: p.website || prev.website,
}))
setMapResults([])
setMapQuery('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
setCategories(prev => [...prev, data.category])
if (onCategoryCreated) onCategoryCreated(data.category)
setFormData(prev => ({ ...prev, category_id: data.category.id }))
setNewCategoryName('')
setShowNewCategory(false)
toast.success('Category created')
} catch (err) {
toast.error('Failed to create category')
}
}
const handleCreateTag = async () => {
if (!newTagName.trim()) return
try {
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
setTags(prev => [...prev, data.tag])
if (onTagCreated) onTagCreated(data.tag)
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
setNewTagName('')
setShowNewTag(false)
toast.success('Tag created')
} catch (err) {
toast.error('Failed to create tag')
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Edit Place' : 'Add Place'}
size="xl"
footer={
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Saving...
</>
) : isEditing ? 'Save Changes' : 'Add Place'}
</button>
</div>
}
>
<div className="space-y-5">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* Place search — Google Maps or OpenStreetMap fallback */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
{!hasMapsKey && (
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('places.osmActive')}
</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={mapQuery}
onChange={e => 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"
/>
</div>
<button
onClick={handleMapSearch}
disabled={mapSearching}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
>
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : t('common.search')}
</button>
</div>
{mapResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
{mapResults.map((p, i) => (
<button
key={p.google_place_id || i}
onClick={() => selectMapPlace(p)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
>
<p className="text-sm font-medium text-slate-900">{p.name}</p>
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3" />
{p.address}
</p>
{p.rating && (
<p className="text-xs text-amber-600 mt-0.5"> {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={e => 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"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={e => update('description', e.target.value)}
placeholder="Notes about this place..."
rows={2}
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 resize-none"
/>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
<input
type="text"
value={formData.address}
onChange={e => update('address', e.target.value)}
placeholder="Street address"
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"
/>
</div>
{/* Lat / Lng */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
<input
type="number"
step="any"
value={formData.lat}
onChange={e => update('lat', e.target.value)}
placeholder="e.g. 48.8584"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
<input
type="number"
step="any"
value={formData.lng}
onChange={e => update('lng', e.target.value)}
placeholder="e.g. 2.2945"
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"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
<div className="flex gap-2">
<select
value={formData.category_id}
onChange={e => update('category_id', e.target.value)}
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
>
<option value="">No category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategory(!showNewCategory)}
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
title="Create new category"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showNewCategory && (
<div className="mt-2 flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="Category name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newCategoryColor}
onChange={e => setNewCategoryColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
title="Category color"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
formData.tags.includes(tag.id)
? 'text-white shadow-sm ring-2 ring-offset-1'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color || '#374151',
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
}}
>
{tag.name}
</button>
))}
<button
type="button"
onClick={() => setShowNewTag(!showNewTag)}
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
>
<Plus className="inline w-3 h-3 mr-0.5" />
New tag
</button>
</div>
{showNewTag && (
<div className="flex gap-2">
<input
type="text"
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="Tag name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newTagColor}
onChange={e => setNewTagColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
/>
<button
type="button"
onClick={handleCreateTag}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Reservation */}
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select
value={formData.reservation_status}
onChange={e => update('reservation_status', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
>
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Reservation details */}
{formData.reservation_status !== 'none' && (
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
<input
type="datetime-local"
value={formData.reservation_datetime}
onChange={e => update('reservation_datetime', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
<textarea
value={formData.reservation_notes}
onChange={e => update('reservation_notes', e.target.value)}
placeholder="Confirmation number, special requests..."
rows={2}
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 resize-none bg-white"
/>
</div>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
<input
type="url"
value={formData.website}
onChange={e => update('website', e.target.value)}
placeholder="https://..."
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"
/>
</div>
</div>
</Modal>
)
}

View File

@@ -1,138 +0,0 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, MapPin } from 'lucide-react'
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
const { place } = assignment
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: `assignment-${assignment.id}`,
data: {
type: 'assignment',
dayId: dayId,
assignment,
},
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`
group bg-white border rounded-lg p-2.5 transition-all
${isDragging
? 'opacity-40 border-slate-300 shadow-lg'
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
}
`}
>
<div className="flex items-start gap-2">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
tabIndex={-1}
>
<GripVertical className="w-4 h-4" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Name row */}
<div className="flex items-center gap-1.5 mb-1">
{place.category && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
</div>
{/* Time & price row */}
<div className="flex items-center gap-2 mb-1">
{place.place_time && (
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
<Clock className="w-3 h-3" />
{place.place_time}
</span>
)}
{place.price != null && (
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{place.tags.map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{onEdit && (
<button
onClick={() => onEdit(place, assignment.id)}
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
title="Edit place"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => onRemove(assignment.id)}
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Remove from day"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,177 +0,0 @@
import React, { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import AssignedPlaceItem from './AssignedPlaceItem'
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
export default function DayColumn({
day,
assignments,
tripId,
onRemoveAssignment,
onEditPlace,
onQuickAdd,
}) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [showNotes, setShowNotes] = useState(false)
const [notes, setNotes] = useState(day.notes || '')
const [notesEditing, setNotesEditing] = useState(false)
const { isOver, setNodeRef } = useDroppable({
id: `day-${day.id}`,
data: {
type: 'day',
dayId: day.id,
},
})
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
const totalCost = (assignments || []).reduce((sum, a) => {
return sum + (a.place?.price ? Number(a.place.price) : 0)
}, 0)
const formatDate = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
return (
<div
className={`
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
${isOver
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
: 'border-transparent bg-white shadow-sm'
}
`}
>
{/* Header */}
<div
className={`
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
{assignments?.length || 0}
</span>
</div>
{day.date && (
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
)}
</div>
<div className="flex items-center gap-1">
{totalCost > 0 && (
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{totalCost.toLocaleString()}
</span>
)}
<button
onClick={() => setShowNotes(!showNotes)}
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Notes"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
</div>
</div>
{/* Notes area */}
{showNotes && (
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
onBlur={() => setNotesEditing(false)}
onFocus={() => setNotesEditing(true)}
placeholder="Add notes for this day..."
rows={2}
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
/>
{notesEditing && (
<div className="flex gap-2 mt-1">
<button
onMouseDown={(e) => {
e.preventDefault()
// Parent will handle save via onUpdateNotes if passed
}}
className="text-xs text-slate-600 hover:text-slate-900"
>
Save
</button>
</div>
)}
</div>
)}
{/* Assignments list */}
{!isCollapsed && (
<div
ref={setNodeRef}
className={`
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
${isOver ? 'bg-slate-50' : 'bg-transparent'}
`}
>
{assignments && assignments.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{assignments.map(assignment => (
<AssignedPlaceItem
key={assignment.id}
assignment={assignment}
dayId={day.id}
onRemove={(id) => onRemoveAssignment(day.id, id)}
onEdit={onEditPlace}
/>
))}
</SortableContext>
) : (
<div className={`
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
text-xs text-center transition-colors
${isOver
? 'border-slate-400 bg-slate-100 text-slate-500'
: 'border-slate-200 text-slate-400'
}
`}>
<Package className="w-8 h-8 mb-2 opacity-50" />
<p className="font-medium">Drop places here</p>
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
</div>
)}
{/* Quick add button */}
<button
onClick={() => onQuickAdd(day)}
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
>
<Plus className="w-3.5 h-3.5" />
Add place
</button>
</div>
)}
{isCollapsed && (
<div
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
onClick={() => setIsCollapsed(false)}
>
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} click to expand
</div>
)}
</div>
)
}

View File

@@ -9,13 +9,19 @@ import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
}
function WIcon({ main, size = 14 }) {
interface WIconProps {
main: string
size?: number
}
function WIcon({ main, size = 14 }: WIconProps) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
@@ -32,7 +38,21 @@ function formatTime12(val, is12h) {
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
interface DayDetailPanelProps {
day: Day
days: Day[]
places: Place[]
categories?: Category[]
tripId: number
assignments: AssignmentsMap
reservations?: Reservation[]
lat: number | null
lng: number | null
onClose: () => void
onAccommodationChange: () => void
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
const { t, language } = useTranslation()
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -504,7 +524,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
}
function Chip({ icon: Icon, value }) {
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
@@ -513,7 +538,16 @@ function Chip({ icon: Icon, value }) {
)
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)

View File

@@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
@@ -13,36 +17,9 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
function formatDate(dateStr, locale) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'short', day: 'numeric', month: 'short',
})
}
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const parts = timeStr.split(':')
const h = Number(parts[0]) || 0
const m = Number(parts[1]) || 0
if (isNaN(h)) return timeStr
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return locale?.startsWith('de') ? `${str} Uhr` : str
} catch { return timeStr }
}
function dayTotalCost(dayId, assignments, currency) {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
}
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -74,6 +51,31 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
interface DayPlanSidebarProps {
tripId: number
trip: Trip
days: Day[]
places: Place[]
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
selectedPlaceId: number | null
selectedAssignmentId: number | null
onSelectDay: (dayId: number | null) => void
onPlaceClick: (placeId: number) => void
onDayDetail: (day: Day) => void
accommodations?: Assignment[]
onReorder: (dayId: number, orderedIds: number[]) => void
onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
onAddReservation: () => void
}
export default function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
@@ -83,14 +85,14 @@ export default function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
onAddReservation,
}) {
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const dayNotes = tripStore.dayNotes || {}
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
const [expandedDays, setExpandedDays] = useState(() => {
try {
@@ -109,9 +111,7 @@ export default function DayPlanSidebar({
const [dropTargetKey, setDropTargetKey] = useState(null)
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
const inputRef = useRef(null)
const noteInputRef = useRef(null)
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
const currency = trip?.currency || 'EUR'
@@ -190,40 +190,19 @@ export default function DayPlanSidebar({
const openAddNote = (dayId, e) => {
e?.stopPropagation()
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
setTimeout(() => noteInputRef.current?.focus(), 50)
_openAddNote(dayId, getMergedItems, (id) => {
if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id]))
})
}
const openEditNote = (dayId, note, e) => {
e?.stopPropagation()
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
} catch (err) { toast.error(err.message) }
_openEditNote(dayId, note)
}
const deleteNote = async (dayId, noteId, e) => {
e?.stopPropagation()
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err) { toast.error(err.message) }
await _deleteNote(dayId, noteId)
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
@@ -263,26 +242,14 @@ export default function DayPlanSidebar({
for (const n of noteChanges) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
}
const moveNote = async (dayId, noteId, direction) => {
const merged = getMergedItems(dayId)
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
if (idx === -1) return
let newSortOrder
if (direction === 'up') {
if (idx === 0) return
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
} else {
if (idx >= merged.length - 1) return
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err) { toast.error(err.message) }
await _moveNote(dayId, noteId, direction, getMergedItems)
}
const startEditTitle = (day, e) => {
@@ -369,9 +336,9 @@ export default function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
setDraggingId(null)
setDropTargetKey(null)
@@ -573,11 +540,11 @@ export default function DayPlanSidebar({
const { assignmentId, noteId, fromDayId } = getDragData(e)
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -652,7 +619,7 @@ export default function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -660,7 +627,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
@@ -822,7 +789,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -830,7 +797,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -895,11 +862,11 @@ export default function DayPlanSidebar({
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)

View File

@@ -1,138 +0,0 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTranslation } from '../../i18n'
function formatDate(dateStr) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
function dayTotal(dayId, assignments) {
const dayAssignments = assignments[String(dayId)] || []
return dayAssignments.reduce((sum, a) => {
const cost = parseFloat(a.place?.cost) || 0
return sum + cost
}, 0)
}
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
const { t } = useTranslation()
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
const currency = trip?.currency || 'EUR'
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<h2 className="text-sm font-semibold text-gray-700">{t('planner.dayPlan')}</h2>
<p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</p>
</div>
{/* All places overview option */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
selectedDayId === null
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
<div>
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
{t('planner.allPlaces')}
</p>
<p className="text-xs text-gray-400">{t('planner.overview')}</p>
</div>
</button>
{/* Day list */}
<div className="flex-1 overflow-y-auto">
{days.length === 0 ? (
<div className="px-4 py-6 text-center">
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-gray-400">{t('planner.noDays')}</p>
<p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</p>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const dayAssignments = assignments[String(day.id)] || []
const cost = dayTotal(day.id, assignments)
const placeCount = dayAssignments.length
return (
<button
key={day.id}
onClick={() => onSelectDay(day.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
isSelected
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</span>
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
{day.title || `Tag ${index + 1}`}
</span>
</div>
{day.date && (
<p className="text-xs text-gray-400 mt-1 ml-0.5">
{formatDate(day.date)}
</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{placeCount > 0 && (
<span className="text-xs text-gray-400">
{placeCount === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
</span>
)}
{cost > 0 && (
<span className="text-xs text-emerald-600 font-medium">
{cost.toFixed(0)} {currency}
</span>
)}
</div>
</div>
</div>
{/* Weather for this day */}
{day.date && isSelected && (
<div className="mt-2">
<WeatherWidget date={day.date} compact />
</div>
)}
</button>
)
})
)}
</div>
{/* Budget summary footer */}
{totalCost > 0 && (
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
<span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency}
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,107 +0,0 @@
import React from 'react'
import { useDraggable } from '@dnd-kit/core'
import { MapPin, DollarSign, Check } from 'lucide-react'
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `place-${place.id}`,
data: {
type: 'place',
place,
},
})
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: isDragging ? 999 : undefined,
} : undefined
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
transition-all select-none
${isDragging
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
}
`}
onClick={e => {
if (!isDragging && onEdit) {
e.stopPropagation()
onEdit(place)
}
}}
>
{/* Category left border accent */}
{place.category && (
<div
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<div className="pl-1">
{/* Header */}
<div className="flex items-start justify-between gap-1 mb-1">
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
{place.name}
</p>
{isAssigned && (
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
<Check className="w-3 h-3 text-emerald-600" />
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Price */}
{place.price != null && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-2.5 h-2.5" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{place.tags.slice(0, 3).map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
{place.tags.length > 3 && (
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,225 +0,0 @@
import React, { useState, useEffect } from 'react'
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) {
const { t } = useTranslation()
const [googlePhoto, setGooglePhoto] = useState(null)
const [photoAttribution, setPhotoAttribution] = useState(null)
useEffect(() => {
if (!place?.google_place_id || place?.image_url) {
setGooglePhoto(null)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
setGooglePhoto(data.photoUrl || null)
setPhotoAttribution(data.attribution || null)
})
.catch(() => setGooglePhoto(null))
}, [place?.google_place_id, place?.image_url])
if (!place) return null
const displayPhoto = place.image_url || googlePhoto
const category = categories?.find(c => c.id === place.category_id)
const placeTags = (place.tags || []).map(t =>
tags?.find(tg => tg.id === (t.id || t)) || t
).filter(Boolean)
const assignmentInDay = selectedDayId
? dayAssignments?.find(a => a.place?.id === place.id)
: null
return (
<div className="bg-white">
{/* Image */}
{displayPhoto ? (
<div className="relative">
<img
src={displayPhoto}
alt={place.name}
className="w-full h-40 object-cover"
onError={e => { e.target.style.display = 'none' }}
/>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
{photoAttribution && !place.image_url && (
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
© {photoAttribution}
</div>
)}
</div>
) : (
<div
className="h-24 flex items-center justify-center relative"
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
>
<span className="text-4xl">{category?.icon || '📍'}</span>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
{/* Content */}
<div className="p-4 space-y-3">
{/* Name + category */}
<div>
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
{category && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }}
>
{category.icon} {category.name}
</span>
)}
</div>
{/* Quick info row */}
<div className="flex flex-wrap gap-2">
{place.place_time && (
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
<Clock className="w-3 h-3" />
{place.place_time}
</div>
)}
{place.price > 0 && (
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
<Euro className="w-3 h-3" />
{place.price} {place.currency}
</div>
)}
</div>
{/* Address */}
{place.address && (
<div className="flex items-start gap-1.5 text-xs text-gray-600">
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
<span>{place.address}</span>
</div>
)}
{/* Coordinates */}
{place.lat && place.lng && (
<div className="text-xs text-gray-400">
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
{/* Links */}
<div className="flex gap-2">
{place.website && (
<a
href={place.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<ExternalLink className="w-3 h-3" />
Website
</a>
)}
{place.phone && (
<a
href={`tel:${place.phone}`}
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<Phone className="w-3 h-3" />
{place.phone}
</a>
)}
</div>
{/* Description */}
{place.description && (
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
)}
{/* Notes */}
{place.notes && (
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
</div>
)}
{/* Tags */}
{placeTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{placeTags.map((tag, i) => (
<span
key={tag.id || i}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</div>
)}
{/* Day assignment actions */}
{selectedDayId && (
<div className="pt-1">
{assignmentInDay ? (
<button
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Minus className="w-4 h-4" />
{t('planner.removeFromDay')}
</button>
) : (
<button
onClick={() => onAssignToDay(place.id)}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
>
<Plus className="w-4 h-4" />
{t('planner.addToThisDay')}
</button>
)}
</div>
)}
{/* Edit / Delete */}
<div className="flex gap-2 pt-1">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<Edit2 className="w-3.5 h-3.5" />
{t('common.edit')}
</button>
<button
onClick={onDelete}
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
@@ -7,8 +7,23 @@ import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Place, Category, Assignment } from '../../types'
const DEFAULT_FORM = {
interface PlaceFormData {
name: string
description: string
address: string
lat: string
lng: string
category_id: string
place_time: string
end_time: string
notes: string
transport_mode: string
website: string
}
const DEFAULT_FORM: PlaceFormData = {
name: '',
description: '',
address: '',
@@ -22,10 +37,22 @@ const DEFAULT_FORM = {
website: '',
}
interface PlaceFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
place: Place | null
tripId: number
categories: Category[]
onCategoryCreated: (category: Category) => void
assignmentId: number | null
dayAssignments?: Assignment[]
}
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [],
}) {
}: PlaceFormModalProps) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -70,7 +97,7 @@ export default function PlaceFormModal({
try {
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err) {
} catch (err: unknown) {
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
@@ -97,13 +124,13 @@ export default function PlaceFormModal({
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
setNewCategoryName('')
setShowNewCategory(false)
} catch (err) {
} catch (err: unknown) {
toast.error(t('places.categoryCreateError'))
}
}
const handleFileAdd = (e) => {
const files = Array.from(e.target.files || [])
const files = Array.from((e.target as HTMLInputElement).files || [])
setPendingFiles(prev => [...prev, ...files])
e.target.value = ''
}
@@ -116,7 +143,7 @@ export default function PlaceFormModal({
const handlePaste = (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()
@@ -144,8 +171,8 @@ export default function PlaceFormModal({
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
onClose()
} catch (err) {
toast.error(err.message || t('places.saveError'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsSaving(false)
}
@@ -371,7 +398,16 @@ export default function PlaceFormModal({
)
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
interface TimeSectionProps {
form: PlaceFormData
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
assignmentId: number | null
dayAssignments: Assignment[]
hasTimeError: boolean
t: (key: string, params?: Record<string, string | number>) => string
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
const collisions = useMemo(() => {
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []

View File

@@ -5,6 +5,7 @@ import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
const detailsCache = new Map()
@@ -97,11 +98,37 @@ function formatFileSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface PlaceInspectorProps {
place: Place | null
categories: Category[]
days: Day[]
selectedDayId: number | null
selectedAssignmentId: number | null
assignments: AssignmentsMap
reservations?: Reservation[]
onClose: () => void
onEdit: () => void
onDelete: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
}
export default function PlaceInspector({
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
}) {
}: PlaceInspectorProps) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false)
@@ -147,7 +174,7 @@ export default function PlaceInspector({
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
const handleFileUpload = useCallback(async (e) => {
const selectedFiles = Array.from(e.target.files || [])
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
if (!selectedFiles.length || !onFileUpload) return
setIsUploading(true)
try {
@@ -158,7 +185,7 @@ export default function PlaceInspector({
await onFileUpload(fd)
}
setFilesExpanded(true)
} catch (err) {
} catch (err: unknown) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
@@ -488,7 +515,14 @@ export default function PlaceInspector({
)
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
interface ChipProps {
icon: React.ReactNode
text: React.ReactNode
color?: string
bg?: string
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }: ChipProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
@@ -497,7 +531,12 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
)
}
function Row({ icon, children }) {
interface RowProps {
icon: React.ReactNode
children: React.ReactNode
}
function Row({ icon, children }: RowProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flexShrink: 0 }}>{icon}</div>
@@ -506,7 +545,14 @@ function Row({ icon, children }) {
)
}
function ActionButton({ onClick, variant, icon, label }) {
interface ActionButtonProps {
onClick: () => void
variant: 'primary' | 'ghost' | 'danger'
icon: React.ReactNode
label: React.ReactNode
}
function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
@@ -531,7 +577,17 @@ function ActionButton({ onClick, variant, icon, label }) {
)
}
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
interface ParticipantsBoxProps {
tripMembers: TripMember[]
participantIds: number[]
allJoined: boolean
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
selectedAssignmentId: number | null
selectedDayId: number | null
t: (key: string) => string
}
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }: ParticipantsBoxProps) {
const [showAdd, setShowAdd] = React.useState(false)
const [hoveredId, setHoveredId] = React.useState(null)

View File

@@ -1,223 +0,0 @@
import React, { useState, useMemo } from 'react'
import DraggablePlaceCard from './DraggablePlaceCard'
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
export default function PlacesPanel({
places,
categories,
tags,
assignments,
tripId,
onAddPlace,
onEditPlace,
hasMapKey,
onSearchMaps,
}) {
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedTags, setSelectedTags] = useState([])
const [showFilters, setShowFilters] = useState(false)
// Get set of assigned place IDs (for any day)
const assignedPlaceIds = useMemo(() => {
const ids = new Set()
Object.values(assignments || {}).forEach(dayAssignments => {
dayAssignments.forEach(a => {
if (a.place?.id) ids.add(a.place.id)
})
})
return ids
}, [assignments])
const filteredPlaces = useMemo(() => {
return places.filter(place => {
if (search) {
const q = search.toLowerCase()
if (!place.name.toLowerCase().includes(q) &&
!place.address?.toLowerCase().includes(q) &&
!place.description?.toLowerCase().includes(q)) {
return false
}
}
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
return false
}
if (selectedTags.length > 0) {
const placeTags = (place.tags || []).map(t => t.id)
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
return false
}
}
return true
})
}, [places, search, selectedCategory, selectedTags])
const toggleTag = (tagId) => {
setSelectedTags(prev =>
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
)
}
const clearFilters = () => {
setSearch('')
setSelectedCategory('')
setSelectedTags([])
}
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
return (
<div className="flex flex-col h-full bg-white border-r border-slate-200">
{/* Header */}
<div className="p-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800">
Places
<span className="ml-1.5 text-xs font-normal text-slate-400">
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
</span>
</h2>
<div className="flex gap-1">
{hasMapKey && (
<button
onClick={onSearchMaps}
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
title="Search Google Maps"
>
<Map className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded-lg transition-colors ${
showFilters || hasActiveFilters
? 'text-slate-700 bg-slate-50'
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
}`}
title="Filters"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search places..."
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Filters */}
{showFilters && (
<div className="mt-2 space-y-2">
{/* Category filter */}
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
>
<option value="">All categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
)}
{/* Tag filters */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map(tag => (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
selectedTags.includes(tag.id)
? 'text-white shadow-sm'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</button>
))}
</div>
)}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear filters
</button>
)}
</div>
)}
</div>
{/* Add place button */}
<div className="px-3 py-2 border-b border-slate-100">
<button
onClick={onAddPlace}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
>
<Plus className="w-4 h-4" />
Add Place
</button>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
{filteredPlaces.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-slate-400" />
</div>
{places.length === 0 ? (
<>
<p className="text-sm font-medium text-slate-600">No places yet</p>
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
<button
onClick={onAddPlace}
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
>
+ Add your first place
</button>
</>
) : (
<>
<p className="text-sm font-medium text-slate-600">No matches found</p>
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
</>
)}
</div>
) : (
filteredPlaces.map(place => (
<DraggablePlaceCard
key={place.id}
place={place}
isAssigned={assignedPlaceIds.has(place.id)}
onEdit={onEditPlace}
/>
))
)}
</div>
</div>
)
}

View File

@@ -1,16 +1,33 @@
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { useState } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps {
places: Place[]
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
selectedPlaceId: number | null
onPlaceClick: (placeId: number | null) => void
onAddPlace: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
days: Day[]
isMobile: boolean
}
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
}) {
}: PlacesSidebarProps) {
const { t } = useTranslation()
const ctxMenu = useContextMenu()
const [search, setSearch] = useState('')

View File

@@ -1,876 +0,0 @@
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
import { FixedSizeList } from 'react-window'
import {
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
CalendarDays, FileText, Check, Pencil, Trash2,
} from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import FileManager from '../Files/FileManager'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
function formatShortDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
day: 'numeric', month: 'short',
})
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch { return dt }
}
export default function PlannerSidebar({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeSegment, setActiveSegment] = useState('plan')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [expandedDays, setExpandedDays] = useState(new Set())
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
const [noteUi, setNoteUi] = useState({})
const noteInputRef = useRef(null)
const tripStore = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: t('planner.places') },
{ id: 'reservierungen', label: t('planner.bookings') },
{ id: 'packliste', label: t('planner.packingList') },
{ id: 'dokumente', label: t('planner.documents') },
]
const dayNotes = tripStore.dayNotes || {}
const placesListRef = useRef(null)
const [placesListHeight, setPlacesListHeight] = useState(400)
useLayoutEffect(() => {
if (!placesListRef.current) return
const ro = new ResizeObserver(([entry]) => {
setPlacesListHeight(entry.contentRect.height)
})
ro.observe(placesListRef.current)
return () => ro.disconnect()
}, [activeSegment])
// Auto-expand selected day
useEffect(() => {
if (selectedDayId) {
setExpandedDays(prev => new Set([...prev, selectedDayId]))
}
}, [selectedDayId])
const toggleDay = (dayId) => {
setExpandedDays(prev => {
const next = new Set(prev)
if (next.has(dayId)) next.delete(dayId)
else next.add(dayId)
return next
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
const filteredPlaces = useMemo(() => places.filter(p => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchSearch && matchCat
}), [places, search, categoryFilter])
const isAssignedToDay = (placeId) =>
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
const totalCost = days.reduce((sum, d) => {
const da = assignments[String(d.id)] || []
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
}, 0)
const currency = trip?.currency || 'EUR'
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
// Get representative location for a day (first place with coords)
const getDayLocation = (dayId) => {
const da = getDayAssignments(dayId)
const p = da.find(a => a.place?.lat && a.place?.lng)
return p ? { lat: p.place.lat, lng: p.place.lng } : null
}
// Route handlers
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = selectedDayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error(t('planner.minTwoPlaces'))
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, 'walking')
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success(t('planner.routeCalculated'))
} catch {
toast.error(t('planner.routeCalcFailed'))
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || selectedDayAssignments.length < 3) return
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
.filter(Boolean)
// Append assignments without coordinates at end
for (const a of selectedDayAssignments) {
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
}
await onReorder(selectedDayId, reorderedIds)
toast.success(t('planner.routeOptimized'))
}
const handleOpenGoogleMaps = () => {
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(ps)
if (url) window.open(url, '_blank')
else toast.error(t('planner.noGeoPlaces'))
}
const handleMoveUp = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === 0) return
const ids = da.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(dayId, ids)
}
const handleMoveDown = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === da.length - 1) return
const ids = da.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(dayId, ids)
}
// Merge place assignments + day notes into a single sorted list
const getMergedDayItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
return [
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId) => {
const merged = getMergedDayItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId, note) => {
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
}
cancelNote(dayId)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteNote = async (dayId, noteId) => {
try {
await tripStore.deleteDayNote(tripId, dayId, noteId)
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveUp = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx <= 0) return
const newSortOrder = idx >= 2
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
: merged[idx - 1].sortKey - 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveDown = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx === -1 || idx >= merged.length - 1) return
const newSortOrder = idx < merged.length - 2
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
: merged[idx + 1].sortKey + 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success(t('planner.reservationUpdated'))
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('planner.reservationAdded'))
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm(t('planner.confirmDeleteReservation'))) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('planner.reservationDeleted'))
} catch (err) {
toast.error(err.message)
}
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
<button onClick={onEditTrip} className="w-full text-left group">
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
{trip?.title}
</h1>
{(trip?.start_date || trip?.end_date) && (
<p className="text-xs text-gray-400 mt-0.5">
{trip.start_date && formatShortDate(trip.start_date)}
{trip.start_date && trip.end_date && ' '}
{trip.end_date && formatShortDate(trip.end_date)}
{days.length > 0 && ` · ${days.length} ${t('planner.days')}`}
</p>
)}
</button>
</div>
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
{SEGMENTS.map(seg => (
<button
key={seg.id}
onClick={() => setActiveSegment(seg.id)}
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
activeSegment === seg.id
? 'bg-white shadow-sm text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{seg.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* ── PLAN ── */}
{activeSegment === 'plan' && (
<div className="pb-4">
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
}`}>
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
{t('planner.allPlaces')}
</p>
<p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</p>
</div>
</button>
{days.length === 0 ? (
<div className="px-4 py-10 text-center">
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p className="text-sm text-gray-400">{t('planner.noDaysPlanned')}</p>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
{t('planner.editTrip')}
</button>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
const loc = getDayLocation(day.id)
const merged = getMergedDayItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} className="border-b border-gray-50">
<div
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
}`}
onClick={() => {
onSelectDay(day.id)
if (!isExpanded) toggleDay(day.id)
}}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
}`}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{day.title || `Tag ${index + 1}`}
</p>
{da.length > 0 && (
<span className="text-xs text-gray-400 flex-shrink-0">
{da.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
{day.date && loc && (
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
)}
</div>
</div>
<button
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
title={t('planner.addNote')}
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
</button>
</div>
{isExpanded && (
<div className="bg-gray-50/40">
{merged.length === 0 && !dayNoteUi ? (
<div className="px-4 py-4 text-center">
<p className="text-xs text-gray-400">{t('planner.noEntries')}</p>
<button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700"
>
{t('planner.addPlaceShort')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100/60">
{merged.map((item, idx) => {
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
return (
<div
key={`place-${assignment.id}`}
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
}`}
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{place.name}
</p>
{(place.description || place.notes) && (
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
{place.description || place.notes}
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{place.place_time && (
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>
)}
{place.price > 0 && (
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
)}
</div>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
disabled={placeIdx === 0}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
disabled={placeIdx === placeItems.length - 1}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
const note = item.data
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
if (isEditingThis) {
return (
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder={t('planner.notePlaceholder')}
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> {t('common.save')}
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
{t('common.cancel')}
</button>
</div>
</div>
)
}
return (
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
{note.time && (
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
)}
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
<Pencil className="w-3 h-3" />
</button>
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
})}
</div>
)}
{dayNoteUi?.mode === 'add' && (
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder={t('planner.noteExamplePlaceholder')}
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> {t('common.add')}
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
{t('common.cancel')}
</button>
</div>
</div>
)}
{!dayNoteUi && (
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
<button
onClick={() => openAddNote(day.id)}
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
>
<FileText className="w-3 h-3" />
{t('planner.addNote')}
</button>
</div>
)}
{/* Route tools — only for the selected day */}
{isSelected && da.length >= 2 && (
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-300">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
>
<Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
</button>
<button
onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('planner.optimize')}
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('planner.openGoogleMaps')}
</button>
</div>
)}
</div>
)}
</div>
)
})
)}
{totalCost > 0 && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)}
{/* ── ORTE ── */}
{activeSegment === 'orte' && (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className="p-3 space-y-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('planner.searchPlaces')}
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
<X className="w-3.5 h-3.5 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
>
<option value="">{t('planner.allCategories')}</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{t('planner.new')}
</button>
</div>
</div>
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
{t('planner.addFirstPlace')}
</button>
</div>
) : (
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
<FixedSizeList
height={placesListHeight}
itemCount={filteredPlaces.length}
itemSize={68}
overscanCount={10}
width="100%"
>
{({ index, style }) => {
const place = filteredPlaces[index]
const category = categories.find(c => c.id === place.category_id)
const inDay = isAssignedToDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
style={style}
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{inDay
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full"></span>
: selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
>
{t('planner.addToDay')}
</button>
)
}
</div>
</div>
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
</div>
</div>
)
}}
</FixedSizeList>
</div>
)}
</div>
)}
{/* ── RESERVIERUNGEN ── */}
{activeSegment === 'reservierungen' && (
<div>
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
<h3 className="font-medium text-sm text-gray-900">
{t('planner.reservations')}
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{t('common.add')}
</button>
</div>
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">{t('planner.noReservations')}</p>
</div>
) : (
<div className="p-3 space-y-2.5">
{filteredReservations.map(r => (
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
{r.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)}
{r.reservation_end_time && ` ${r.reservation_end_time}`}
</div>
)}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
{r.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
# {r.confirmation_number}
</div>
)}
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
></button>
<button
onClick={() => handleDeleteReservation(r.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
>🗑</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── PACKLISTE ── */}
{activeSegment === 'packliste' && (
<PackingListPanel tripId={tripId} items={packingItems} />
)}
{/* ── DOKUMENTE ── */}
{activeSegment === 'dokumente' && (
<FileManager tripId={tripId} />
)}
</div>
{/* ── INSPECTOR OVERLAY ── */}
{selectedPlace && (
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={selectedDayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -45,7 +46,21 @@ function buildAssignmentOptions(days, assignments, t, locale) {
return options
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
interface ReservationModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
reservation: Reservation | null
days: Day[]
places: Place[]
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void>
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
const toast = useToast()
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
@@ -113,7 +128,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'
import { useState, useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
@@ -8,6 +8,16 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
dayNumber: number
dayTitle: string | null
dayDate: string
placeName: string
startTime: string | null
endTime: string | null
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
@@ -37,7 +47,17 @@ function buildAssignmentLookup(days, assignments) {
return map
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
interface ReservationCardProps {
r: Reservation
tripId: number
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry>
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -176,7 +196,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
interface SectionProps {
title: string
count: number
children: React.ReactNode
defaultOpen?: boolean
accent: 'green' | string
}
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 16 }}>
@@ -197,7 +225,19 @@ function Section({ title, count, children, defaultOpen = true, accent }) {
)
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
interface ReservationsPanelProps {
tripId: number
reservations: Reservation[]
days: Day[]
assignments: AssignmentsMap
files?: TripFile[]
onAdd: () => void
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
onNavigateToFiles: () => void
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))

View File

@@ -1,591 +0,0 @@
import React, { useState, useCallback } from 'react'
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
export function RightPanel({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDay, selectedDayId, selectedPlaceId,
onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeTab, setActiveTab] = useState('orte')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const tripStore = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const TABS = [
{ id: 'orte', label: t('planner.places'), icon: '📍' },
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
]
// Filtered places for Orte tab
const filteredPlaces = places.filter(p => {
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchesSearch && matchesCategory
})
// Ordered assignments for selected day
const dayAssignments = selectedDayId
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
: []
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
// Calculate schedule with times
const getSchedule = () => {
if (!dayAssignments.length) return []
let currentTime = null
return dayAssignments.map((assignment, idx) => {
const place = assignment.place
const startTime = place?.place_time || (currentTime ? currentTime : null)
const duration = place?.duration_minutes || 60
if (startTime) {
const [h, m] = startTime.split(':').map(Number)
const endMinutes = h * 60 + m + duration
const endH = Math.floor(endMinutes / 60) % 24
const endM = endMinutes % 60
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
}
return { assignment, startTime, endTime: currentTime }
})
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = dayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error(t('planner.minTwoPlaces'))
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, 'walking')
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success(t('planner.routeCalculated'))
} else {
toast.error(t('planner.routeCalcFailed'))
}
} catch (err) {
toast.error(t('planner.routeError'))
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || dayAssignments.length < 3) return
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(places)
const optimizedIds = optimized.map(p => {
const a = dayAssignments.find(a => a.place?.id === p.id)
return a?.id
}).filter(Boolean)
await onReorder(selectedDayId, optimizedIds)
toast.success(t('planner.routeOptimized'))
}
const handleOpenGoogleMaps = () => {
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(places)
if (url) window.open(url, '_blank')
else toast.error(t('planner.noGeoPlaces'))
}
const handleMoveUp = async (idx) => {
if (idx === 0) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(selectedDayId, ids)
}
const handleMoveDown = async (idx) => {
if (idx === dayAssignments.length - 1) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(selectedDayId, ids)
}
const handleAddReservation = () => {
setEditingReservation(null)
setShowReservationModal(true)
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success(t('planner.reservationUpdated'))
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('planner.reservationAdded'))
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm(t('planner.confirmDeleteReservation'))) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('planner.reservationDeleted'))
} catch (err) {
toast.error(err.message)
}
}
// Reservations for selected day (or all if no day selected)
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white">
{/* Tabs */}
<div className="flex border-b border-gray-200 flex-shrink-0">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
activeTab === tab.id
? 'text-slate-700 border-b-2 border-slate-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<span className="text-base leading-none">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
{/* ORTE TAB */}
{activeTab === 'orte' && (
<div className="flex flex-col h-full">
{/* Place detail (when selected) */}
{selectedPlace && (
<div className="border-b border-gray-100">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={dayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
{/* Search & filter */}
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('planner.searchPlaces')}
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
<X className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
>
<option value="">{t('planner.allCategories')}</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
{t('planner.addPlace')}
</button>
</div>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto">
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
{t('planner.addFirstPlace')}
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const isInDay = isAssignedToSelectedDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-start gap-2">
{/* Category color bar */}
<div
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isInDay && (
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded"></span>
)}
{!isInDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
>
{t('planner.addToDay')}
</button>
)}
</div>
</div>
{category && (
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
)}
{place.address && (
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
)}
<div className="flex items-center gap-2 mt-1">
{place.place_time && (
<span className="text-xs text-gray-500">🕐 {place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>
)}
{place.price > 0 && (
<span className="text-xs text-gray-500">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* TAGESPLAN TAB */}
{activeTab === 'tagesplan' && (
<div className="flex flex-col h-full">
{!selectedDayId ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
<span className="text-4xl mb-3">📅</span>
<p className="text-sm text-center">{t('planner.selectDayHint')}</p>
</div>
) : (
<>
{/* Day header */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
<h3 className="font-semibold text-slate-900 text-sm">
Tag {selectedDay?.day_number}
{selectedDay?.date && (
<span className="font-normal text-slate-700 ml-2">
{formatGermanDate(selectedDay.date)}
</span>
)}
</h3>
<p className="text-xs text-slate-700 mt-0.5">
{dayAssignments.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
</p>
</div>
{/* Places list with order */}
<div className="flex-1 overflow-y-auto">
{dayAssignments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🗺</span>
<p className="text-sm">{t('planner.noPlacesForDay')}</p>
<button
onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
{t('planner.addPlacesLink')}
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
return (
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
{/* Order number */}
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
style={{ backgroundColor: category?.color || '#6366f1' }}
>
{idx + 1}
</div>
{/* Place info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
<div className="flex items-center gap-2 mt-0.5">
{startTime && (
<span className="text-xs text-slate-700">🕐 {startTime}</span>
)}
<span className="text-xs text-gray-400">
{place.duration_minutes || 60} Min.
</span>
{place.price > 0 && (
<span className="text-xs text-gray-400">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
{place.address && (
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
)}
{assignment.notes && (
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
<button
onClick={() => handleMoveUp(idx)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMoveDown(idx)}
disabled={idx === dayAssignments.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
className="p-1 text-red-400 hover:text-red-600"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Route buttons */}
{dayAssignments.length >= 2 && (
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
>
<Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
</button>
<button
onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('planner.optimize')}
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('planner.openGoogleMaps')}
</button>
</div>
)}
</>
)}
</div>
)}
{/* RESERVIERUNGEN TAB */}
{activeTab === 'reservierungen' && (
<div className="flex flex-col h-full">
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
<h3 className="font-medium text-sm text-gray-900">
{t('planner.reservations')}
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={handleAddReservation}
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
>
<Plus className="w-3.5 h-3.5" />
{t('common.add')}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">{t('planner.noReservations')}</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
{t('planner.addFirstReservation')}
</button>
</div>
) : (
<div className="p-3 space-y-3">
{filteredReservations.map(reservation => (
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
{reservation.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(reservation.reservation_time)}
{reservation.reservation_end_time && ` ${reservation.reservation_end_time}`}
</div>
)}
{reservation.location && (
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
)}
{reservation.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
# {reservation.confirmation_number}
</div>
)}
{reservation.notes && (
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
>
</button>
<button
onClick={() => handleDeleteReservation(reservation.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* PACKLISTE TAB */}
{activeTab === 'packliste' && (
<PackingListPanel
tripId={tripId}
items={packingItems}
/>
)}
</div>
{/* Reservation Modal */}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}
function formatGermanDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}

View File

@@ -1,12 +1,21 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
import { tripsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { Trip } from '../../types'
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
interface TripFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
trip: Trip | null
onCoverUpdate: (tripId: number, coverUrl: string) => void
}
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
const isEditing = !!trip
const fileRef = useRef(null)
const toast = useToast()
@@ -68,8 +77,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}
onClose()
} catch (err) {
setError(err.message || t('places.saveError'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsLoading(false)
}
@@ -88,7 +97,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
const handleCoverChange = (e) => {
handleCoverSelect(e.target.files?.[0])
handleCoverSelect((e.target as HTMLInputElement).files?.[0])
e.target.value = ''
}
@@ -128,7 +137,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const handlePaste = (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/')) {
e.preventDefault()
const file = item.getAsFile()

View File

@@ -1,13 +1,20 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import CustomSelect from '../shared/CustomSelect'
function Avatar({ username, avatarUrl, size = 32 }) {
interface AvatarProps {
username: string
avatarUrl: string | null
size?: number
}
function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
@@ -25,7 +32,14 @@ function Avatar({ username, avatarUrl, size = 32 }) {
)
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
interface TripMembersModalProps {
isOpen: boolean
onClose: () => void
tripId: number
tripTitle: string
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: TripMembersModalProps) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
@@ -71,8 +85,8 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
setSelectedUserId('')
await loadMembers()
toast.success(`${target.username} ${t('members.added')}`)
} catch (err) {
toast.error(err.response?.data?.error || t('members.addError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.addError')))
} finally {
setAdding(false)
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback } from 'react'
import { useMemo, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'

View File

@@ -1,16 +1,29 @@
import React, { useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
import type { HolidaysMap, VacayEntry } from '../../types'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
interface VacayMonthCardProps {
year: number
month: number
holidays: HolidaysMap
companyHolidaySet: Set<string>
companyHolidaysEnabled?: boolean
entryMap: Record<string, VacayEntry[]>
onCellClick: (date: string) => void
companyMode: boolean
blockWeekends: boolean
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends
}) {
}: VacayMonthCardProps) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useState, useEffect } from 'react'
import DOM from 'react-dom'
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
@@ -46,8 +48,8 @@ export default function VacayPersons() {
toast.success(t('vacay.inviteSent'))
setShowInvite(false)
setSelectedInviteUser(null)
} catch (err) {
toast.error(err.response?.data?.error || t('vacay.inviteError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
} finally {
setInviting(false)
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
@@ -6,7 +6,11 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
export default function VacaySettings({ onClose }) {
interface VacaySettingsProps {
onClose: () => void
}
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
@@ -192,7 +196,15 @@ export default function VacaySettings({ onClose }) {
)
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
interface SettingToggleProps {
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
label: string
hint: string
value: boolean
onChange: (value: boolean) => void
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">

View File

@@ -1,8 +1,16 @@
import React, { useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { Briefcase, Pencil } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import type { VacayStat } from '../../types'
interface VacayStatExtended extends VacayStat {
username: string
avatar_url: string | null
color: string | null
total_available: number
}
export default function VacayStats() {
const { t } = useTranslation()
@@ -41,7 +49,16 @@ export default function VacayStats() {
)
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
interface StatCardProps {
stat: VacayStatExtended
isMe: boolean
canEdit: boolean
selectedYear: number
onSave: (userId: number, year: number, days: number) => Promise<void>
t: (key: string) => string
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
const [editing, setEditing] = useState(false)
const [localDays, setLocalDays] = useState(s.vacation_days)
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0

View File

@@ -1,146 +0,0 @@
// German public holidays (Feiertage) calculation per Bundesland
// Includes fixed and Easter-dependent movable holidays
const BUNDESLAENDER = {
BW: 'Baden-Württemberg',
BY: 'Bayern',
BE: 'Berlin',
BB: 'Brandenburg',
HB: 'Bremen',
HH: 'Hamburg',
HE: 'Hessen',
MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz',
SL: 'Saarland',
SN: 'Sachsen',
ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein',
TH: 'Thüringen',
};
// Gauss Easter algorithm
function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function fmt(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function getHolidays(year, bundesland = 'NW') {
const easter = easterSunday(year);
const holidays = {};
// Fixed holidays (nationwide)
holidays[`${year}-01-01`] = 'Neujahr';
holidays[`${year}-05-01`] = 'Tag der Arbeit';
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
// Easter-dependent (nationwide)
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
// State-specific
const bl = bundesland.toUpperCase();
// Heilige Drei Könige (6. Jan) — BW, BY, ST
if (['BW', 'BY', 'ST'].includes(bl)) {
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
}
// Internationaler Frauentag (8. März) — BE, MV
if (['BE', 'MV'].includes(bl)) {
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
}
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
}
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
if (['SL'].includes(bl)) {
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
}
// Weltkindertag (20. Sep) — TH
if (bl === 'TH') {
holidays[`${year}-09-20`] = 'Weltkindertag';
}
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
holidays[`${year}-10-31`] = 'Reformationstag';
}
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[`${year}-11-01`] = 'Allerheiligen';
}
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
if (bl === 'SN') {
const nov23 = new Date(year, 10, 23);
let bbt = new Date(nov23);
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
holidays[fmt(bbt)] = 'Buß- und Bettag';
}
return holidays;
}
export function isWeekend(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const day = d.getDay();
return day === 0 || day === 6;
}
export function getWeekday(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
}
export function getWeekdayFull(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
}
export function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
export function formatDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
}
export { BUNDESLAENDER };

View File

@@ -0,0 +1,131 @@
const BUNDESLAENDER: Record<string, string> = {
BW: 'Baden-Württemberg',
BY: 'Bayern',
BE: 'Berlin',
BB: 'Brandenburg',
HB: 'Bremen',
HH: 'Hamburg',
HE: 'Hessen',
MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz',
SL: 'Saarland',
SN: 'Sachsen',
ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein',
TH: 'Thüringen',
}
function easterSunday(year: number): Date {
const a = year % 19
const b = Math.floor(year / 100)
const c = year % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const month = Math.floor((h + l - 7 * m + 114) / 31)
const day = ((h + l - 7 * m + 114) % 31) + 1
return new Date(year, month - 1, day)
}
function addDays(date: Date, days: number): Date {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d
}
function fmt(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function getHolidays(year: number, bundesland: string = 'NW'): Record<string, string> {
const easter = easterSunday(year)
const holidays: Record<string, string> = {}
holidays[`${year}-01-01`] = 'Neujahr'
holidays[`${year}-05-01`] = 'Tag der Arbeit'
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit'
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag'
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag'
holidays[fmt(addDays(easter, -2))] = 'Karfreitag'
holidays[fmt(addDays(easter, 1))] = 'Ostermontag'
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt'
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag'
const bl = bundesland.toUpperCase()
if (['BW', 'BY', 'ST'].includes(bl)) {
holidays[`${year}-01-06`] = 'Heilige Drei Könige'
}
if (['BE', 'MV'].includes(bl)) {
holidays[`${year}-03-08`] = 'Internationaler Frauentag'
}
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam'
}
if (['SL'].includes(bl)) {
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt'
}
if (bl === 'TH') {
holidays[`${year}-09-20`] = 'Weltkindertag'
}
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
holidays[`${year}-10-31`] = 'Reformationstag'
}
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[`${year}-11-01`] = 'Allerheiligen'
}
if (bl === 'SN') {
const nov23 = new Date(year, 10, 23)
const bbt = new Date(nov23)
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1)
holidays[fmt(bbt)] = 'Buß- und Bettag'
}
return holidays
}
export function isWeekend(dateStr: string): boolean {
const d = new Date(dateStr + 'T00:00:00')
const day = d.getDay()
return day === 0 || day === 6
}
export function getWeekday(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
}
export function getWeekdayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
export function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
}
export { BUNDESLAENDER }

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { weatherApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
@@ -15,7 +15,12 @@ const WEATHER_ICON_MAP = {
Haze: Wind,
}
function WeatherIcon({ main, size = 13 }) {
interface WeatherIconProps {
main: string
size?: number
}
function WeatherIcon({ main, size = 13 }: WeatherIconProps) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
@@ -32,7 +37,14 @@ function setWeatherCache(key, value) {
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
}
export default function WeatherWidget({ lat, lng, date, compact = false }) {
interface WeatherWidgetProps {
lat: number | null
lng: number | null
date: string
compact?: boolean
}
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)

View File

@@ -2,6 +2,17 @@ import React, { useEffect, useCallback } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}
export default function ConfirmDialog({
isOpen,
onClose,
@@ -11,10 +22,10 @@ export default function ConfirmDialog({
confirmLabel,
cancelLabel,
danger = true,
}) {
}: ConfirmDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e) => {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])

View File

@@ -1,10 +1,25 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { LucideIcon } from 'lucide-react'
interface MenuItem {
label?: string
icon?: LucideIcon
onClick?: () => void
danger?: boolean
divider?: boolean
}
interface MenuState {
x: number
y: number
items: MenuItem[]
}
export function useContextMenu() {
const [menu, setMenu] = useState(null) // { x, y, items }
const [menu, setMenu] = useState<MenuState | null>(null)
const open = (e, items) => {
const open = (e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
@@ -15,8 +30,13 @@ export function useContextMenu() {
return { menu, open, close }
}
export function ContextMenu({ menu, onClose }) {
const ref = useRef(null)
interface ContextMenuProps {
menu: MenuState | null
onClose: () => void
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!menu) return
@@ -29,7 +49,6 @@ export function ContextMenu({ menu, onClose }) {
}
}, [menu, onClose])
// Adjust position if menu would overflow viewport
useEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
@@ -60,7 +79,7 @@ export function ContextMenu({ menu, onClose }) {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
const Icon = item.icon
return (
<button key={i} onClick={() => { item.onClick(); onClose() }} style={{
<button key={i} onClick={() => { item.onClick?.(); onClose() }} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', borderRadius: 7, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',

View File

@@ -3,24 +3,30 @@ import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
// ── Datum-Only Picker ────────────────────────────────────────────────────────
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDatePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
const { locale, t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
@@ -36,12 +42,12 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const selectDay = (day) => {
const selectDay = (day: number) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
@@ -51,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const today = new Date()
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
@@ -81,11 +87,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const vh = window.innerHeight
let left = r.left
let top = r.bottom + 4
// Keep within viewport horizontally
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
// If not enough space below, open above
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
// On very small screens, center horizontally
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
@@ -161,16 +164,21 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
)
}
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDateTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
const { locale } = useTranslation()
// value = "2024-03-15T14:30" oder ""
const [datePart, timePart] = (value || '').split('T')
const handleDateChange = (d) => {
const handleDateChange = (d: string) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
const handleTimeChange = (t) => {
const handleTimeChange = (t: string) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : d)
}
@@ -185,5 +193,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
)
}
// Inline re-export for convenience
import CustomTimePicker from './CustomTimePicker'

View File

@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
interface SelectOption {
value: string
label: string
icon?: React.ReactNode
isHeader?: boolean
searchLabel?: string
groupLabel?: string
}
interface CustomSelectProps {
value: string
onChange: (value: string) => void
options?: SelectOption[]
placeholder?: string
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
}
export default function CustomSelect({
value,
onChange,
options = [], // [{ value, label, icon? }]
options = [],
placeholder = '',
searchable = false,
style = {},
size = 'md', // 'sm' | 'md'
}) {
size = 'md',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
const dropRef = useRef(null)
const searchRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
}, [open, searchable])
useEffect(() => {
const handleClick = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
@@ -35,8 +54,8 @@ export default function CustomSelect({
const filtered = searchable && search
? (() => {
const q = search.toLowerCase()
const result = []
let currentHeader = null
const result: SelectOption[] = []
let currentHeader: SelectOption | null = null
let headerAdded = false
for (const o of options) {
if (o.isHeader) {
@@ -44,7 +63,6 @@ export default function CustomSelect({
headerAdded = false
continue
}
// Match against label, searchLabel, or groupLabel
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes(q)) {
if (currentHeader && !headerAdded) {

View File

@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val, is12h) {
function formatDisplay(val: string, is12h: boolean): string {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
interface CustomTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }: CustomTimePickerProps) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const [h, m] = (value || '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const update = (newH, newM) => {
const update = (newH: number, newM: number) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
onChange(`${hh}:${mm}`)
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
update(newH, newM)
}
const btnStyle = {
const btnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
transition: 'color 0.15s',
}
const handleInput = (e) => {
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
// Auto-format: wenn "1430" → "14:30"
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
@@ -139,7 +145,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Stunden */}
{/* Hours */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -163,7 +169,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
{/* Minuten */}
{/* Minutes */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
@@ -9,6 +9,16 @@ const sizeClasses = {
'2xl': 'max-w-4xl',
}
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
children?: React.ReactNode
size?: string
footer?: React.ReactNode
hideCloseButton?: boolean
}
export default function Modal({
isOpen,
onClose,
@@ -17,8 +27,8 @@ export default function Modal({
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
}: ModalProps) {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
@@ -33,7 +43,7 @@ export default function Modal({
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
if (!isOpen) return null

View File

@@ -1,25 +1,37 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
import type { Place } from '../../types'
const googlePhotoCache = new Map()
interface Category {
color?: string
icon?: string
}
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
size?: number
category?: Category | null
}
const googlePhotoCache = new Map<string, string>()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
.then((data: { photoUrl?: string }) => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
@@ -30,7 +42,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
const containerStyle: React.CSSProperties = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',

View File

@@ -1,14 +1,28 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: number
message: string
type: ToastType
duration: number
removing: boolean
}
declare global {
interface Window {
__addToast?: (message: string, type?: ToastType, duration?: number) => number
}
}
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
@@ -24,27 +38,26 @@ export function ToastContainer() {
return id
}, [])
const removeToast = useCallback((id) => {
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
const bgColors: Record<ToastType, string> = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
@@ -78,17 +91,17 @@ export function ToastContainer() {
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
const show = useCallback((message: string, type: ToastType, duration?: number) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
success: (message: string, duration?: number) => show(message, 'success', duration),
error: (message: string, duration?: number) => show(message, 'error', duration),
warning: (message: string, duration?: number) => show(message, 'warning', duration),
info: (message: string, duration?: number) => show(message, 'info', duration),
}
}

View File

@@ -9,9 +9,10 @@ import {
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
LucideIcon,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
@@ -24,7 +25,7 @@ export const CATEGORY_ICON_MAP = {
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
export const ICON_LABELS: Record<string, string> = {
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
@@ -38,6 +39,6 @@ export const ICON_LABELS = {
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
return (iconName && CATEGORY_ICON_MAP[iconName]) || MapPin
}

View File

@@ -0,0 +1,78 @@
import { useState, useRef } from 'react'
import { useTripStore } from '../store/tripStore'
import { useToast } from '../components/shared/Toast'
import type { MergedItem, DayNotesMap, DayNote } from '../types'
interface NoteUiState {
mode: 'add' | 'edit'
noteId?: number
text: string
time: string
icon: string
sortOrder?: number
}
interface NoteUiMap {
[dayId: string]: NoteUiState
}
export function useDayNotes(tripId: number | string) {
const [noteUi, setNoteUi] = useState<NoteUiMap>({})
const noteInputRef = useRef<HTMLInputElement | null>(null)
const tripStore = useTripStore()
const toast = useToast()
const dayNotes: DayNotesMap = tripStore.dayNotes || {}
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map((i) => i.sortKey)) : -1
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
expandDay?.(dayId)
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId: number, note: DayNote) => {
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId: number) => {
setNoteUi((prev) => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId: number) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const deleteNote = async (dayId: number, noteId: number) => {
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => {
const merged = getMergedItems(dayId)
const idx = merged.findIndex((i) => i.type === 'note' && (i.data as DayNote).id === noteId)
if (idx === -1) return
let newSortOrder: number
if (direction === 'up') {
if (idx === 0) return
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
} else {
if (idx >= merged.length - 1) return
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote }
}

View File

@@ -0,0 +1,18 @@
import { useState, useCallback } from 'react'
export function usePlaceSelection() {
const [selectedPlaceId, _setSelectedPlaceId] = useState<number | null>(null)
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number | null>(null)
const setSelectedPlaceId = useCallback((placeId: number | null) => {
_setSelectedPlaceId(placeId)
setSelectedAssignmentId(null)
}, [])
const selectAssignment = useCallback((assignmentId: number | null, placeId: number | null) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
return { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment }
}

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useRef } from 'react'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
export function useResizablePanels() {
const [leftWidth, setLeftWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340)
const [rightWidth, setRightWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarRightWidth') || '') || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false)
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (isResizingLeft.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
setLeftWidth(w)
localStorage.setItem('sidebarLeftWidth', String(w))
}
if (isResizingRight.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
setRightWidth(w)
localStorage.setItem('sidebarRightWidth', String(w))
}
}
const onUp = () => {
isResizingLeft.current = false
isResizingRight.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
}, [])
const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight }
}

View File

@@ -0,0 +1,44 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { calculateSegments } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
const [route, setRoute] = useState<[number, number][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController()
routeAbortRef.current = controller
try {
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(segments)
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, tripStore.assignments])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}

View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react'
import { useTripStore } from '../store/tripStore'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import type { WebSocketEvent } from '../types'
export function useTripWebSocket(tripId: number | string | undefined) {
const tripStore = useTripStore()
useEffect(() => {
if (!tripId) return
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
const collabFileSync = (event: WebSocketEvent) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
}

View File

@@ -1,36 +0,0 @@
import React, { createContext, useContext, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
const translations = { de, en }
const TranslationContext = createContext({ t: (k) => k, language: 'de', locale: 'de-DE' })
export function TranslationProvider({ children }) {
const language = useSettingsStore(s => s.settings.language) || 'de'
const value = useMemo(() => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key, params) {
let val = strings[key] ?? fallback[key] ?? key
// Arrays/Objects direkt zurückgeben (z.B. Vorschläge-Liste)
if (typeof val !== 'string') return val
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), v)
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation() {
return useContext(TranslationContext)
}

View File

@@ -0,0 +1,47 @@
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
type TranslationStrings = Record<string, string>
const translations: Record<string, TranslationStrings> = { de, en }
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
language: string
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
interface TranslationProviderProps {
children: ReactNode
}
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'de'
const value = useMemo((): TranslationContextValue => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key: string, params?: Record<string, string | number>): string {
let val: string = strings[key] ?? fallback[key] ?? key
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation(): TranslationContextValue {
return useContext(TranslationContext)
}

View File

@@ -1,4 +1,4 @@
const de = {
const de: Record<string, string> = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -138,12 +138,14 @@ const de = {
'settings.oidcLinked': 'Verknüpft mit',
'settings.changePassword': 'Passwort ändern',
'settings.currentPassword': 'Aktuelles Passwort',
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
'settings.newPassword': 'Neues Passwort',
'settings.confirmPassword': 'Neues Passwort bestätigen',
'settings.updatePassword': 'Passwort aktualisieren',
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
'settings.passwordChanged': 'Passwort erfolgreich geändert',
'settings.deleteAccount': 'Löschen',
'settings.deleteAccountTitle': 'Account wirklich löschen?',

View File

@@ -1,4 +1,4 @@
const en = {
const en: Record<string, string> = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -138,12 +138,14 @@ const en = {
'settings.oidcLinked': 'Linked with',
'settings.changePassword': 'Change Password',
'settings.currentPassword': 'Current password',
'settings.currentPasswordRequired': 'Current password is required',
'settings.newPassword': 'New password',
'settings.confirmPassword': 'Confirm new password',
'settings.updatePassword': 'Update password',
'settings.passwordRequired': 'Please enter current and new password',
'settings.passwordTooShort': 'Password must be at least 8 characters',
'settings.passwordMismatch': 'Passwords do not match',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
'settings.passwordChanged': 'Password changed successfully',
'settings.deleteAccount': 'Delete account',
'settings.deleteAccountTitle': 'Delete your account?',

View File

@@ -1,10 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />

View File

@@ -4,6 +4,7 @@ import { adminApi, authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
@@ -14,7 +15,41 @@ import AddonManager from '../components/Admin/AddonManager'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
interface AdminUser {
id: number
username: string
email: string
role: 'admin' | 'user'
created_at: string
last_login?: string | null
online?: boolean
oidc_issuer?: string | null
}
interface AdminStats {
totalUsers: number
totalTrips: number
totalPlaces: number
totalFiles: number
}
interface OidcConfig {
issuer: string
client_id: string
client_secret: string
client_secret_set: boolean
display_name: string
}
interface UpdateInfo {
update_available: boolean
latest: string
current: string
release_url?: string
is_docker?: boolean
}
export default function AdminPage(): React.ReactElement {
const { demoMode } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -27,39 +62,39 @@ export default function AdminPage() {
{ id: 'github', label: t('admin.tabs.github') },
]
const [activeTab, setActiveTab] = useState('users')
const [users, setUsers] = useState([])
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
const [activeTab, setActiveTab] = useState<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// OIDC config
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
const [savingOidc, setSavingOidc] = useState(false)
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState(false)
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
const [showKeys, setShowKeys] = useState({})
const [savingKeys, setSavingKeys] = useState(false)
const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({})
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState(null)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const [updating, setUpdating] = useState<boolean>(false)
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate()
@@ -84,7 +119,7 @@ export default function AdminPage() {
])
setUsers(usersData.users)
setStats(statsData)
} catch (err) {
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
@@ -96,7 +131,7 @@ export default function AdminPage() {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err) {
} catch (err: unknown) {
// ignore
}
}
@@ -106,7 +141,7 @@ export default function AdminPage() {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
} catch (err) {
} catch (err: unknown) {
// ignore
}
}
@@ -135,9 +170,9 @@ export default function AdminPage() {
setAllowRegistration(value)
try {
await authApi.updateAppSettings({ allow_registration: value })
} catch (err) {
} catch (err: unknown) {
setAllowRegistration(!value)
toast.error(err.response?.data?.error || t('common.error'))
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
@@ -153,8 +188,8 @@ export default function AdminPage() {
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSavingKeys(false)
}
@@ -167,7 +202,7 @@ export default function AdminPage() {
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err) {
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating({})
@@ -181,7 +216,7 @@ export default function AdminPage() {
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err) {
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
@@ -199,8 +234,8 @@ export default function AdminPage() {
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.createError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
}
}
@@ -221,8 +256,8 @@ export default function AdminPage() {
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
}
}
@@ -236,8 +271,8 @@ export default function AdminPage() {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
}
}
@@ -676,6 +711,7 @@ export default function AdminPage() {
type="password"
value={oidcConfig.client_secret}
onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -683,10 +719,12 @@ export default function AdminPage() {
onClick={async () => {
setSavingOidc(true)
try {
await adminApi.updateOidc(oidcConfig)
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name }
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved'))
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSavingOidc(false)
}

View File

@@ -6,9 +6,43 @@ import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
// Convert country code to flag emoji
function MobileStats({ data, stats, countries, resolveName, t, dark }) {
interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
}
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a'
const tf = dark ? '#475569' : '#94a3b8'
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
@@ -57,40 +91,40 @@ function MobileStats({ data, stats, countries, resolveName, t, dark }) {
)
}
function countryCodeToFlag(code) {
function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
function useCountryNames(language) {
const [resolver, setResolver] = useState(() => (code) => code)
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
setResolver(() => (code) => { try { return dn.of(code) } catch { return code } })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
const A2_TO_A3 = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
export default function AtlasPage() {
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef(null)
const mapInstance = useRef(null)
const geoLayerRef = useRef(null)
const glareRef = useRef(null)
const borderGlareRef = useRef(null)
const panelRef = useRef(null)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const handlePanelMouseMove = (e) => {
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -108,13 +142,13 @@ export default function AtlasPage() {
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [selectedCountry, setSelectedCountry] = useState(null)
const [countryDetail, setCountryDetail] = useState(null)
const [geoData, setGeoData] = useState(null)
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
// Load atlas data
useEffect(() => {
@@ -256,7 +290,7 @@ export default function AtlasPage() {
}).addTo(mapInstance.current)
}, [geoData, data, dark])
const loadCountryDetail = async (code) => {
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
@@ -345,7 +379,20 @@ export default function AtlasPage() {
)
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }) {
interface SidebarContentProps {
data: AtlasData | null
stats: AtlasStats
countries: AtlasCountry[]
selectedCountry: string | null
countryDetail: CountryDetail | null
resolveName: (code: string) => string
onCountryClick: (code: string) => void
onTripClick: (id: number) => void
t: TranslationFn
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'

View File

@@ -4,6 +4,7 @@ import { tripsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
@@ -15,16 +16,33 @@ import {
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
} from 'lucide-react'
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
[key: string]: string | number | boolean | null | undefined
}
function daysUntil(dateStr) {
const font: React.CSSProperties = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
const MS_PER_DAY = 86400000
function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d - today) / 86400000)
return Math.round((d - today) / MS_PER_DAY)
}
function getTripStatus(trip) {
function getTripStatus(trip: DashboardTrip): string | null {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
@@ -35,17 +53,17 @@ function getTripStatus(trip) {
return 'past'
}
function formatDate(dateStr, locale = 'de-DE') {
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatDateShort(dateStr, locale = 'de-DE') {
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
function sortTrips(trips) {
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = new Date().toISOString().split('T')[0]
function rank(t) {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
@@ -72,15 +90,23 @@ const GRADIENTS = [
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
]
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
// ── Liquid Glass hover effect ────────────────────────────────────────────────
function LiquidGlass({ children, dark, style, className = '', onClick }) {
const ref = useRef(null)
const glareRef = useRef(null)
const borderRef = useRef(null)
interface LiquidGlassProps {
children: React.ReactNode
dark: boolean
style?: React.CSSProperties
className?: string
onClick?: () => void
}
const onMove = (e) => {
function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidGlassProps): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderRef = useRef<HTMLDivElement>(null)
const onMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!ref.current || !glareRef.current || !borderRef.current) return
const rect = ref.current.getBoundingClientRect()
const x = e.clientX - rect.left
@@ -109,7 +135,18 @@ function LiquidGlass({ children, dark, style, className = '', onClick }) {
}
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) {
interface TripCardProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onDelete: (trip: DashboardTrip) => void
onArchive: (id: number) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
dark?: boolean
}
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -190,7 +227,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -279,7 +316,17 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
}
// ── Archived Trip Row ────────────────────────────────────────────────────────
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
interface ArchivedRowProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onUnarchive: (id: number) => void
onDelete: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
}
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -322,7 +369,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function Stat({ value, label }) {
function Stat({ value, label }: { value: number | string; label: string }): React.ReactElement {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
@@ -331,7 +378,7 @@ function Stat({ value, label }) {
)
}
function CardAction({ onClick, icon, label, danger }) {
function CardAction({ onClick, icon, label, danger }: { onClick: () => void; icon: React.ReactNode; label: string; danger?: boolean }): React.ReactElement {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
@@ -345,7 +392,7 @@ function CardAction({ onClick, icon, label, danger }) {
)
}
function IconBtn({ onClick, title, danger, loading, children }) {
function IconBtn({ onClick, title, danger, loading, children }: { onClick: () => void; title: string; danger?: boolean; loading?: boolean; children: React.ReactNode }): React.ReactElement {
return (
<button onClick={onClick} title={title} disabled={loading} style={{
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
@@ -361,7 +408,7 @@ function IconBtn({ onClick, title, danger, loading, children }) {
}
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard() {
function SkeletonCard(): React.ReactElement {
return (
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
@@ -374,14 +421,14 @@ function SkeletonCard() {
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [trips, setTrips] = useState([])
const [archivedTrips, setArchivedTrips] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [showArchived, setShowArchived] = useState(false)
const [showWidgetSettings, setShowWidgetSettings] = useState(false)
export default function DashboardPage(): React.ReactElement {
const [trips, setTrips] = useState<DashboardTrip[]>([])
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
const navigate = useNavigate()
const toast = useToast()
@@ -427,8 +474,8 @@ export default function DashboardPage() {
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
return data
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
}
}
@@ -437,8 +484,8 @@ export default function DashboardPage() {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
}
}
@@ -476,8 +523,8 @@ export default function DashboardPage() {
}
}
const handleCoverUpdate = (tripId, coverImage) => {
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
const handleCoverUpdate = (tripId: number, coverImage: string | null): void => {
const update = (t: DashboardTrip) => t.id === tripId ? { ...t, cover_image: coverImage } : t
setTrips(prev => prev.map(update))
setArchivedTrips(prev => prev.map(update))
}

View File

@@ -6,23 +6,24 @@ import Navbar from '../components/Layout/Navbar'
import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Place, TripFile } from '../types'
export default function FilesPage() {
export default function FilesPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [places, setPlaces] = useState([])
const [files, setFiles] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [trip, setTrip] = useState<Trip | null>(null)
const [places, setPlaces] = useState<Place[]>([])
const [files, setFiles] = useState<TripFile[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
@@ -32,7 +33,7 @@ export default function FilesPage() {
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err) {
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
@@ -43,11 +44,11 @@ export default function FilesPage() {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData) => {
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId) => {
const handleDelete = async (fileId: number): Promise<void> => {
await tripStore.deleteFile(tripId, fileId)
}
@@ -61,7 +62,7 @@ export default function FilesPage() {
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-5xl mx-auto px-4 py-6">
@@ -78,7 +79,7 @@ export default function FilesPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dateien & Dokumente</h1>
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.title}</p>
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.name}</p>
</div>
</div>

View File

@@ -6,44 +6,58 @@ import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
export default function LoginPage() {
interface AppConfig {
has_users: boolean
allow_registration: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
}
export default function LoginPage(): React.ReactElement {
const { t, language } = useTranslation()
const [mode, setMode] = useState('login') // 'login' | 'register'
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [appConfig, setAppConfig] = useState(null)
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const { login, register, demoLogin } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then(config => {
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
// Handle OIDC callback token (via URL fragment to avoid logging)
const hash = window.location.hash.substring(1)
const hashParams = new URLSearchParams(hash)
const token = hashParams.get('token')
// Handle OIDC callback via short-lived auth code (secure exchange)
const params = new URLSearchParams(window.location.search)
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (token) {
localStorage.setItem('auth_token', token)
if (oidcCode) {
window.history.replaceState({}, '', '/login')
login.__fromOidc = true
navigate('/dashboard')
window.location.reload()
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
.then(r => r.json())
.then(data => {
if (data.token) {
localStorage.setItem('auth_token', data.token)
navigate('/dashboard')
window.location.reload()
} else {
setError(data.error || 'OIDC login failed')
}
})
.catch(() => setError('OIDC login failed'))
}
if (oidcError) {
const errorMessages = {
const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'),
no_email: t('login.oidc.noEmail'),
token_failed: t('login.oidc.tokenFailed'),
@@ -54,23 +68,23 @@ export default function LoginPage() {
}
}, [])
const handleDemoLogin = async () => {
const handleDemoLogin = async (): Promise<void> => {
setError('')
setIsLoading(true)
try {
await demoLogin()
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) {
setError(err.message || t('login.demoFailed'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.demoFailed'))
} finally {
setIsLoading(false)
}
}
const [showTakeoff, setShowTakeoff] = useState(false)
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
@@ -84,15 +98,15 @@ export default function LoginPage() {
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) {
setError(err.message || t('login.error'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.error'))
setIsLoading(false)
}
}
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
const inputBase = {
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
@@ -264,8 +278,8 @@ export default function LoginPage() {
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
>
<Globe size={14} />
{language === 'en' ? 'EN' : 'DE'}
@@ -392,8 +406,8 @@ export default function LoginPage() {
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
].map(({ Icon, label, desc }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
@@ -441,10 +455,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text" value={username} onChange={e => setUsername(e.target.value)} required
type="text" value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} required
placeholder="admin" style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
@@ -456,10 +470,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email} onChange={e => setEmail(e.target.value)} required
type="email" value={email} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
placeholder={t('login.emailPlaceholder')} style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
@@ -470,10 +484,10 @@ export default function LoginPage() {
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPassword ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} required
type={showPassword ? 'text' : 'password'} value={password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} required
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
@@ -490,8 +504,8 @@ export default function LoginPage() {
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={e => e.currentTarget.style.background = '#111827'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
@@ -530,8 +544,8 @@ export default function LoginPage() {
textDecoration: 'none', transition: 'all 0.15s',
boxSizing: 'border-box',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
>
<Shield size={16} />
{t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
@@ -551,8 +565,8 @@ export default function LoginPage() {
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
>
<Plane size={18} />
{t('login.demoHint')}

View File

@@ -6,24 +6,25 @@ import Navbar from '../components/Layout/Navbar'
import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Day, Place, Photo } from '../types'
export default function PhotosPage() {
export default function PhotosPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [days, setDays] = useState([])
const [places, setPlaces] = useState([])
const [photos, setPhotos] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [trip, setTrip] = useState<Trip | null>(null)
const [days, setDays] = useState<Day[]>([])
const [places, setPlaces] = useState<Place[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
@@ -37,7 +38,7 @@ export default function PhotosPage() {
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err) {
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
@@ -49,15 +50,15 @@ export default function PhotosPage() {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData) => {
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId) => {
const handleDelete = async (photoId: number): Promise<void> => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId, data) => {
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
await tripStore.updatePhoto(tripId, photoId, data)
}
@@ -71,7 +72,7 @@ export default function PhotosPage() {
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-7xl mx-auto px-4 py-6">
@@ -89,7 +90,7 @@ export default function PhotosPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Fotos</h1>
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.title}</p>
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.name}</p>
</div>
</div>

View File

@@ -4,20 +4,20 @@ import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
export default function RegisterPage() {
export default function RegisterPage(): React.ReactElement {
const { t } = useTranslation()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const { register } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
@@ -35,8 +35,8 @@ export default function RegisterPage() {
try {
await register(username, email, password)
navigate('/dashboard')
} catch (err) {
setError(err.message || t('register.failed'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('register.failed'))
} finally {
setIsLoading(false)
}
@@ -96,7 +96,7 @@ export default function RegisterPage() {
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
required
placeholder="johndoe"
minLength={3}
@@ -112,7 +112,7 @@ export default function RegisterPage() {
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
required
placeholder="your@email.com"
className="w-full pl-10 pr-4 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 transition-all"
@@ -127,7 +127,7 @@ export default function RegisterPage() {
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
required
placeholder={t('register.minChars')}
className="w-full pl-10 pr-12 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 transition-all"
@@ -149,7 +149,7 @@ export default function RegisterPage() {
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
required
placeholder={t('register.repeatPassword')}
className="w-full pl-10 pr-4 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 transition-all"

View File

@@ -8,8 +8,16 @@ import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
import type { LucideIcon } from 'lucide-react'
import type { UserWithOidc } from '../types'
import { getApiErrorMessage } from '../types'
const MAP_PRESETS = [
interface MapPreset {
name: string
url: string
}
const MAP_PRESETS: MapPreset[] = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
@@ -17,7 +25,13 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
function Section({ title, icon: Icon, children }) {
interface SectionProps {
title: string
icon: LucideIcon
children: React.ReactNode
}
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
@@ -31,31 +45,32 @@ function Section({ title, icon: Icon, children }) {
)
}
export default function SettingsPage() {
export default function SettingsPage(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const avatarInputRef = React.useRef(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [saving, setSaving] = useState({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
// Map settings
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
// Display
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
// Account
const [username, setUsername] = useState(user?.username || '')
const [email, setEmail] = useState(user?.email || '')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [username, setUsername] = useState<string>(user?.username || '')
const [email, setEmail] = useState<string>(user?.email || '')
const [currentPassword, setCurrentPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -70,36 +85,36 @@ export default function SettingsPage() {
setEmail(user?.email || '')
}, [user])
const saveMapSettings = async () => {
const saveMapSettings = async (): Promise<void> => {
setSaving(s => ({ ...s, map: true }))
try {
await updateSettings({
map_tile_url: mapTileUrl,
default_lat: parseFloat(defaultLat),
default_lng: parseFloat(defaultLng),
default_zoom: parseInt(defaultZoom),
default_lat: parseFloat(String(defaultLat)),
default_lng: parseFloat(String(defaultLng)),
default_zoom: parseInt(String(defaultZoom)),
})
toast.success(t('settings.toast.mapSaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, map: false }))
}
}
const saveDisplay = async () => {
const saveDisplay = async (): Promise<void> => {
setSaving(s => ({ ...s, display: true }))
try {
await updateSetting('temperature_unit', tempUnit)
toast.success(t('settings.toast.displaySaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, display: false }))
}
}
const handleAvatarUpload = async (e) => {
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
try {
@@ -111,7 +126,7 @@ export default function SettingsPage() {
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
const handleAvatarRemove = async () => {
const handleAvatarRemove = async (): Promise<void> => {
try {
await deleteAvatar()
toast.success(t('settings.avatarRemoved'))
@@ -120,13 +135,13 @@ export default function SettingsPage() {
}
}
const saveProfile = async () => {
const saveProfile = async (): Promise<void> => {
setSaving(s => ({ ...s, profile: true }))
try {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err) {
toast.error(err.message)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(s => ({ ...s, profile: false }))
}
@@ -149,7 +164,7 @@ export default function SettingsPage() {
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value=""
onChange={value => { if (value) setMapTileUrl(value) }}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({
value: p.url,
@@ -161,7 +176,7 @@ export default function SettingsPage() {
<input
type="text"
value={mapTileUrl}
onChange={e => setMapTileUrl(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
@@ -175,7 +190,7 @@ export default function SettingsPage() {
type="number"
step="any"
value={defaultLat}
onChange={e => setDefaultLat(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -185,7 +200,7 @@ export default function SettingsPage() {
type="number"
step="any"
value={defaultLng}
onChange={e => setDefaultLng(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -220,7 +235,7 @@ export default function SettingsPage() {
onClick={async () => {
try {
await updateSetting('dark_mode', opt.value)
} catch (e) { toast.error(e.message) }
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -252,7 +267,7 @@ export default function SettingsPage() {
key={opt.value}
onClick={async () => {
try { await updateSetting('language', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -283,7 +298,7 @@ export default function SettingsPage() {
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -313,7 +328,7 @@ export default function SettingsPage() {
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -342,7 +357,7 @@ export default function SettingsPage() {
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e) { toast.error(e.message) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -368,7 +383,7 @@ export default function SettingsPage() {
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -377,7 +392,7 @@ export default function SettingsPage() {
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
@@ -386,37 +401,45 @@ export default function SettingsPage() {
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)}
placeholder={t('settings.newPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
placeholder={t('settings.confirmPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ new_password: newPassword })
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setNewPassword(''); setConfirmPassword('')
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Lock size={14} />
{t('settings.updatePassword')}
@@ -455,8 +478,8 @@ export default function SettingsPage() {
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
>
<Camera size={14} />
</button>
@@ -481,7 +504,7 @@ export default function SettingsPage() {
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
{user?.oidc_issuer && (
{(user as UserWithOidc)?.oidc_issuer && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
@@ -491,9 +514,9 @@ export default function SettingsPage() {
</span>
)}
</div>
{user?.oidc_issuer && (
{(user as UserWithOidc)?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
{t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')}
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
</p>
)}
</div>
@@ -513,7 +536,7 @@ export default function SettingsPage() {
if (user?.role === 'admin') {
try {
const data = await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter(u => u.role === 'admin')
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
@@ -541,7 +564,7 @@ export default function SettingsPage() {
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} style={{ color: '#d97706' }} />
@@ -576,7 +599,7 @@ export default function SettingsPage() {
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} style={{ color: '#ef4444' }} />
@@ -603,8 +626,8 @@ export default function SettingsPage() {
await authApi.deleteOwnAccount()
logout()
navigate('/login')
} catch (err) {
toast.error(err.response?.data?.error || t('common.error'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
@@ -21,16 +21,16 @@ import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
import { calculateRoute, calculateSegments } from '../components/Map/RouteCalculator'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
export default function TripPlannerPage() {
const { id: tripId } = useParams()
export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
@@ -38,10 +38,10 @@ export default function TripPlannerPage() {
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
const [allowedFileTypes, setAllowedFileTypes] = useState(null)
const [tripMembers, setTripMembers] = useState([])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
@@ -67,52 +67,30 @@ export default function TripPlannerPage() {
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
]
const [activeTab, setActiveTab] = useState(() => {
const [activeTab, setActiveTab] = useState<string>(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
const handleTabChange = (tabId) => {
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
}
const [leftWidth, setLeftWidth] = useState(() => parseInt(localStorage.getItem('sidebarLeftWidth')) || 340)
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false)
const [showDayDetail, setShowDayDetail] = useState(null) // day object or null
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
const [selectedPlaceId, _setSelectedPlaceId] = useState(null)
const [selectedAssignmentId, setSelectedAssignmentId] = useState(null)
// Set place selection - from PlacesSidebar/Map (no assignment context)
const setSelectedPlaceId = useCallback((placeId) => {
_setSelectedPlaceId(placeId)
setSelectedAssignmentId(null)
}, [])
// Set assignment selection - from DayPlanSidebar (specific assignment)
const selectAssignment = useCallback((assignmentId, placeId) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null)
const [editingAssignmentId, setEditingAssignmentId] = useState(null)
const [showTripForm, setShowTripForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [route, setRoute] = useState(null)
const [routeInfo, setRouteInfo] = useState(null) // unused legacy
const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText }
const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
const [deletePlaceId, setDeletePlaceId] = useState(null)
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [fitKey, setFitKey] = useState<number>(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
// Load trip + files (needed for place inspector file section)
useEffect(() => {
@@ -132,87 +110,13 @@ export default function TripPlannerPage() {
if (tripId) tripStore.loadReservations(tripId)
}, [tripId])
// WebSocket: join trip and listen for remote events
useEffect(() => {
if (!tripId) return
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
// Reload files when collab notes change (attachments sync) — from WebSocket (other users)
const collabFileSync = (event) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
// Reload files when local collab actions change files (own user)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
useEffect(() => {
const onMove = (e) => {
if (isResizingLeft.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
setLeftWidth(w)
localStorage.setItem('sidebarLeftWidth', w)
}
if (isResizingRight.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
setRightWidth(w)
localStorage.setItem('sidebarRightWidth', w)
}
}
const onUp = () => {
isResizingLeft.current = false
isResizingRight.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
}, [])
useTripWebSocket(tripId)
const mapPlaces = useCallback(() => {
return places.filter(p => p.lat && p.lng)
}, [places])
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
const routeAbortRef = useRef(null)
const updateRouteForDay = useCallback(async (dayId) => {
// Abort any previous calculation
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map(p => [p.lat, p.lng]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
// Single OSRM request for all segments
const controller = new AbortController()
routeAbortRef.current = controller
try {
const segments = await calculateSegments(waypoints, { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(segments)
} catch (err) {
if (err.name !== 'AbortError') setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId
@@ -287,7 +191,7 @@ export default function TripPlannerPage() {
await tripStore.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
@@ -297,14 +201,14 @@ export default function TripPlannerPage() {
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
try {
await tripStore.removeAssignment(tripId, dayId, assignmentId)
}
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, tripStore, toast, updateRouteForDay])
const handleReorder = useCallback((dayId, orderedIds) => {
@@ -323,7 +227,7 @@ export default function TripPlannerPage() {
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripStore.updateDayTitle(tripId, dayId, title) }
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, tripStore, toast])
const handleSaveReservation = async (data) => {
@@ -339,12 +243,12 @@ export default function TripPlannerPage() {
setShowReservationModal(false)
return r
}
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const handleDeleteReservation = async (id) => {
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
catch (err) { toast.error(err.message) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
@@ -363,12 +267,6 @@ export default function TripPlannerPage() {
return map
}, [selectedDayId, assignments])
// Auto-update route + segments when assignments change
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
@@ -510,7 +408,7 @@ export default function TripPlannerPage() {
/>
{!leftCollapsed && (
<div
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
onMouseDown={startResizeLeft}
style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -547,7 +445,7 @@ export default function TripPlannerPage() {
}}>
{!rightCollapsed && (
<div
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
onMouseDown={startResizeRight}
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -647,7 +545,7 @@ export default function TripPlannerPage() {
}))
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err) { toast.error(err.message) } }}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
/>
)}

View File

@@ -11,17 +11,17 @@ import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import Modal from '../components/shared/Modal'
export default function VacayPage() {
export default function VacayPage(): React.ReactElement {
const { t } = useTranslation()
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
const [showSettings, setShowSettings] = useState(false)
const [deleteYear, setDeleteYear] = useState(null)
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
useEffect(() => { loadAll() }, [])
// Live sync via WebSocket
const handleWsMessage = useCallback((msg) => {
const handleWsMessage = useCallback((msg: { type: string }) => {
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
loadPlan()
loadEntries(selectedYear)
@@ -263,7 +263,7 @@ export default function VacayPage() {
)
}
function InfoItem({ icon: Icon, text }) {
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement {
return (
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} />
@@ -272,7 +272,7 @@ function InfoItem({ icon: Icon, text }) {
)
}
function LegendItem({ color, label }) {
function LegendItem({ color, label }: { color: string; label: string }): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} />

View File

@@ -1,8 +1,42 @@
import { create } from 'zustand'
import { authApi } from '../api/client'
import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
import { getApiErrorMessage } from '../types'
export const useAuthStore = create((set, get) => ({
interface AuthResponse {
user: User
token: string
}
interface AvatarResponse {
avatar_url: string
}
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
demoMode: boolean
hasMapsKey: boolean
login: (email: string, password: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string) => Promise<AuthResponse>
logout: () => void
loadUser: () => Promise<void>
updateMapsKey: (key: string | null) => Promise<void>
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
updateProfile: (profileData: Partial<User>) => Promise<void>
uploadAvatar: (file: File) => Promise<AvatarResponse>
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token') || null,
isAuthenticated: !!localStorage.getItem('auth_token'),
@@ -11,7 +45,7 @@ export const useAuthStore = create((set, get) => ({
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
login: async (email, password) => {
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password })
@@ -25,14 +59,14 @@ export const useAuthStore = create((set, get) => ({
})
connect(data.token)
return data
} catch (err) {
const error = err.response?.data?.error || 'Login failed'
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
register: async (username, email, password) => {
register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password })
@@ -46,8 +80,8 @@ export const useAuthStore = create((set, get) => ({
})
connect(data.token)
return data
} catch (err) {
const error = err.response?.data?.error || 'Registration failed'
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
set({ isLoading: false, error })
throw new Error(error)
}
@@ -79,7 +113,7 @@ export const useAuthStore = create((set, get) => ({
isLoading: false,
})
connect(token)
} catch (err) {
} catch (err: unknown) {
localStorage.removeItem('auth_token')
set({
user: null,
@@ -90,55 +124,55 @@ export const useAuthStore = create((set, get) => ({
}
},
updateMapsKey: async (key) => {
updateMapsKey: async (key: string | null) => {
try {
await authApi.updateMapsKey(key)
set(state => ({
user: { ...state.user, maps_api_key: key || null }
set((state) => ({
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error saving API key')
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
}
},
updateApiKeys: async (keys) => {
updateApiKeys: async (keys: Record<string, string | null>) => {
try {
const data = await authApi.updateApiKeys(keys)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Error saving API keys')
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
},
updateProfile: async (profileData) => {
updateProfile: async (profileData: Partial<User>) => {
try {
const data = await authApi.updateSettings(profileData)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating profile')
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating profile'))
}
},
uploadAvatar: async (file) => {
uploadAvatar: async (file: File) => {
const formData = new FormData()
formData.append('avatar', file)
const data = await authApi.uploadAvatar(formData)
set(state => ({ user: { ...state.user, avatar_url: data.avatar_url } }))
set((state) => ({ user: state.user ? { ...state.user, avatar_url: data.avatar_url } : null }))
return data
},
deleteAvatar: async () => {
await authApi.deleteAvatar()
set(state => ({ user: { ...state.user, avatar_url: null } }))
set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null }))
},
setDemoMode: (val) => {
setDemoMode: (val: boolean) => {
if (val) localStorage.setItem('demo_mode', 'true')
else localStorage.removeItem('demo_mode')
set({ demoMode: val })
},
setHasMapsKey: (val) => set({ hasMapsKey: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })
@@ -155,8 +189,8 @@ export const useAuthStore = create((set, get) => ({
})
connect(data.token)
return data
} catch (err) {
const error = err.response?.data?.error || 'Demo login failed'
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Demo login failed')
set({ isLoading: false, error })
throw new Error(error)
}

View File

@@ -1,61 +0,0 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
export const useSettingsStore = create((set, get) => ({
settings: {
map_tile_url: '',
default_lat: 48.8566,
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
},
isLoaded: false,
loadSettings: async () => {
try {
const data = await settingsApi.get()
set(state => ({
settings: { ...state.settings, ...data.settings },
isLoaded: true,
}))
} catch (err) {
set({ isLoaded: true })
console.error('Failed to load settings:', err)
}
},
updateSetting: async (key, value) => {
set(state => ({
settings: { ...state.settings, [key]: value }
}))
if (key === 'language') localStorage.setItem('app_language', value)
try {
await settingsApi.set(key, value)
} catch (err) {
console.error('Failed to save setting:', err)
throw new Error(err.response?.data?.error || 'Error saving setting')
}
},
setLanguageLocal: (lang) => {
localStorage.setItem('app_language', lang)
set(state => ({ settings: { ...state.settings, language: lang } }))
},
updateSettings: async (settingsObj) => {
set(state => ({
settings: { ...state.settings, ...settingsObj }
}))
try {
await settingsApi.setBulk(settingsObj)
} catch (err) {
console.error('Failed to save settings:', err)
throw new Error(err.response?.data?.error || 'Error saving settings')
}
},
}))

View File

@@ -0,0 +1,73 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
import type { Settings } from '../types'
import { getApiErrorMessage } from '../types'
interface SettingsState {
settings: Settings
isLoaded: boolean
loadSettings: () => Promise<void>
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
setLanguageLocal: (lang: string) => void
updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
settings: {
map_tile_url: '',
default_lat: 48.8566,
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
},
isLoaded: false,
loadSettings: async () => {
try {
const data = await settingsApi.get()
set((state) => ({
settings: { ...state.settings, ...data.settings },
isLoaded: true,
}))
} catch (err: unknown) {
set({ isLoaded: true })
console.error('Failed to load settings:', err)
}
},
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
set((state) => ({
settings: { ...state.settings, [key]: value },
}))
if (key === 'language') localStorage.setItem('app_language', value as string)
try {
await settingsApi.set(key, value)
} catch (err: unknown) {
console.error('Failed to save setting:', err)
throw new Error(getApiErrorMessage(err, 'Error saving setting'))
}
},
setLanguageLocal: (lang: string) => {
localStorage.setItem('app_language', lang)
set((state) => ({ settings: { ...state.settings, language: lang } }))
},
updateSettings: async (settingsObj: Partial<Settings>) => {
set((state) => ({
settings: { ...state.settings, ...settingsObj },
}))
try {
await settingsApi.setBulk(settingsObj)
} catch (err: unknown) {
console.error('Failed to save settings:', err)
throw new Error(getApiErrorMessage(err, 'Error saving settings'))
}
},
}))

View File

@@ -0,0 +1,168 @@
import { assignmentsApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface AssignmentsSlice {
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
moveAssignment: (tripId: number | string, assignmentId: number, fromDayId: number | string, toDayId: number | string, toOrderIndex?: number | null) => Promise<void>
setAssignments: (assignments: AssignmentsMap) => void
}
export const createAssignmentsSlice = (set: SetState, get: GetState): AssignmentsSlice => ({
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === parseInt(String(placeId)))
if (!place) return
const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length
const tempAssignment: Assignment = {
id: tempId,
day_id: parseInt(String(dayId)),
order_index: insertIdx,
notes: null,
place,
}
current.splice(insertIdx, 0, tempAssignment)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: current,
}
}))
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = {
...data.assignment,
place: data.assignment.place || place,
order_index: position != null ? insertIdx : data.assignment.order_index,
}
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].map(
a => a.id === tempId ? newAssignment : a
),
}
}))
if (position != null) {
const updated = get().assignments[String(dayId)] || []
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
if (orderedIds.length > 0) {
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
set(state => {
const items = state.assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = items.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
return {
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}
})
} catch {}
}
}
return data.assignment
} catch (err: unknown) {
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
}
}))
throw new Error(getApiErrorMessage(err, 'Error assigning place'))
}
},
removeAssignment: async (tripId, dayId, assignmentId) => {
const prevAssignments = get().assignments
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
}
}))
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error removing assignment'))
}
},
reorderAssignments: async (tripId, dayId, orderedIds) => {
const prevAssignments = get().assignments
const dayItems = get().assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = dayItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}))
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error reordering'))
}
},
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
const state = get()
const prevAssignments = state.assignments
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
if (!assignment) return
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
const newToItems = [...toItems]
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(String(toDayId)) })
newToItems.forEach((a, i) => { a.order_index = i })
set(s => ({
assignments: {
...s.assignments,
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
[String(toDayId)]: newToItems,
}
}))
try {
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
if (newToItems.length > 1) {
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
}
} catch (err: unknown) {
set({ assignments: prevAssignments })
throw new Error(getApiErrorMessage(err, 'Error moving assignment'))
}
},
setAssignments: (assignments) => {
set({ assignments })
},
})

View File

@@ -0,0 +1,82 @@
import { budgetApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { BudgetItem, BudgetMember } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface BudgetSlice {
loadBudgetItems: (tripId: number | string) => Promise<void>
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
}
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
loadBudgetItems: async (tripId) => {
try {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
} catch (err: unknown) {
console.error('Failed to load budget items:', err)
}
},
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding budget item'))
}
},
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
}
},
deleteBudgetItem: async (tripId, id) => {
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
}
},
setBudgetItemMembers: async (tripId, itemId, userIds) => {
const result = await budgetApi.setMembers(tripId, itemId, userIds);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
)
}));
return result;
},
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
await budgetApi.togglePaid(tripId, itemId, userId, paid);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
: item
)
}));
},
})

View File

@@ -0,0 +1,135 @@
import { daysApi, dayNotesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface DayNotesSlice {
updateDayNotes: (tripId: number | string, dayId: number | string, notes: string) => Promise<void>
updateDayTitle: (tripId: number | string, dayId: number | string, title: string) => Promise<void>
addDayNote: (tripId: number | string, dayId: number | string, data: Partial<DayNote>) => Promise<DayNote>
updateDayNote: (tripId: number | string, dayId: number | string, id: number, data: Partial<DayNote>) => Promise<DayNote>
deleteDayNote: (tripId: number | string, dayId: number | string, id: number) => Promise<void>
moveDayNote: (tripId: number | string, fromDayId: number | string, toDayId: number | string, noteId: number, sort_order?: number) => Promise<void>
}
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating notes'))
}
},
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating day name'))
}
},
addDayNote: async (tripId, dayId, data) => {
const tempId = Date.now() * -1
const tempNote: DayNote = { id: tempId, day_id: dayId as number, ...data, created_at: new Date().toISOString() } as DayNote
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
}
}))
return result.note
} catch (err: unknown) {
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
}
}))
throw new Error(getApiErrorMessage(err, 'Error adding note'))
}
},
updateDayNote: async (tripId, dayId, id, data) => {
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
}
}))
return result.note
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating note'))
}
},
deleteDayNote: async (tripId, dayId, id) => {
const prev = get().dayNotes
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) {
set({ dayNotes: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting note'))
}
},
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
const state = get()
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
if (!note) return
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
}
}))
try {
await dayNotesApi.delete(tripId, fromDayId, noteId)
const result = await dayNotesApi.create(tripId, toDayId, {
text: note.text, time: note.time, icon: note.icon, sort_order,
})
set(s => ({
dayNotes: {
...s.dayNotes,
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
}
}))
} catch (err: unknown) {
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
}
}))
throw new Error(getApiErrorMessage(err, 'Error moving note'))
}
},
})

View File

@@ -0,0 +1,44 @@
import { filesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TripFile } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface FilesSlice {
loadFiles: (tripId: number | string) => Promise<void>
addFile: (tripId: number | string, formData: FormData) => Promise<TripFile>
deleteFile: (tripId: number | string, id: number) => Promise<void>
}
export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
set({ files: data.files })
} catch (err: unknown) {
console.error('Failed to load files:', err)
}
},
addFile: async (tripId, formData) => {
try {
const data = await filesApi.upload(tripId, formData)
set(state => ({ files: [data.file, ...state.files] }))
return data.file
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error uploading file'))
}
},
deleteFile: async (tripId, id) => {
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
})

View File

@@ -0,0 +1,67 @@
import { packingApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { PackingItem } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface PackingSlice {
addPackingItem: (tripId: number | string, data: Partial<PackingItem>) => Promise<PackingItem>
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
}
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
addPackingItem: async (tripId, data) => {
try {
const result = await packingApi.create(tripId, data)
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding item'))
}
},
updatePackingItem: async (tripId, id, data) => {
try {
const result = await packingApi.update(tripId, id, data)
set(state => ({
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating item'))
}
},
deletePackingItem: async (tripId, id) => {
const prev = get().packingItems
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
try {
await packingApi.delete(tripId, id)
} catch (err: unknown) {
set({ packingItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
}
},
togglePackingItem: async (tripId, id, checked) => {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await packingApi.update(tripId, id, { checked })
} catch {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
})

View File

@@ -0,0 +1,71 @@
import { placesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Place, Assignment } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface PlacesSlice {
refreshPlaces: (tripId: number | string) => Promise<void>
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
}
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
refreshPlaces: async (tripId) => {
try {
const data = await placesApi.list(tripId)
set({ places: data.places })
} catch (err: unknown) {
console.error('Failed to refresh places:', err)
}
},
addPlace: async (tripId, placeData) => {
try {
const data = await placesApi.create(tripId, placeData)
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding place'))
}
},
updatePlace: async (tripId, placeId, placeData) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
])
),
}))
return data.place
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating place'))
}
},
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter((a: Assignment) => a.place?.id !== placeId)
])
),
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
}
},
})

View File

@@ -0,0 +1,239 @@
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, Place, Day, DayNote, PackingItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
/**
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
*/
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
const { type, ...payload } = event
set(state => {
switch (type) {
// Places
case 'place:created':
if (state.places.some(p => p.id === (payload.place as Place).id)) return {}
return { places: [payload.place as Place, ...state.places] }
case 'place:updated':
return {
places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a)
])
),
}
case 'place:deleted':
return {
places: state.places.filter(p => p.id !== payload.placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== payload.placeId)
])
),
}
// Assignments
case 'assignment:created': {
const dayKey = String((payload.assignment as Assignment).day_id)
const existing = (state.assignments[dayKey] || [])
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
if (hasTempVersion) {
return {
assignments: {
...state.assignments,
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
}
}
}
return {}
}
return {
assignments: {
...state.assignments,
[dayKey]: [...existing, payload.assignment as Assignment],
}
}
}
case 'assignment:updated': {
const dayKey = String((payload.assignment as Assignment).day_id)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).map(a =>
a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a
),
}
}
}
case 'assignment:deleted': {
const dayKey = String(payload.dayId)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
}
}
}
case 'assignment:moved': {
const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment as Assignment
return {
assignments: {
...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
}
}
}
case 'assignment:reordered': {
const dayKey = String(payload.dayId)
const currentItems = state.assignments[dayKey] || []
const orderedIds: number[] = payload.orderedIds || []
const reordered = orderedIds.map((id, idx) => {
const item = currentItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter((item): item is Assignment => item !== null)
return {
assignments: {
...state.assignments,
[dayKey]: reordered,
}
}
}
// Days
case 'day:created':
if (state.days.some(d => d.id === (payload.day as Day).id)) return {}
return { days: [...state.days, payload.day as Day] }
case 'day:updated':
return {
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
}
case 'day:deleted': {
const removedDayId = String(payload.dayId)
const newAssignments = { ...state.assignments }
delete newAssignments[removedDayId]
const newDayNotes = { ...state.dayNotes }
delete newDayNotes[removedDayId]
return {
days: state.days.filter(d => d.id !== payload.dayId),
assignments: newAssignments,
dayNotes: newDayNotes,
}
}
// Day Notes
case 'dayNote:created': {
const dayKey = String(payload.dayId)
const existingNotes = (state.dayNotes[dayKey] || [])
if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {}
return {
dayNotes: {
...state.dayNotes,
[dayKey]: [...existingNotes, payload.note as DayNote],
}
}
}
case 'dayNote:updated': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n),
}
}
}
case 'dayNote:deleted': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
}
}
}
// Packing
case 'packing:created':
if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {}
return { packingItems: [...state.packingItems, payload.item as PackingItem] }
case 'packing:updated':
return {
packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i),
}
case 'packing:deleted':
return {
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
}
// Budget
case 'budget:created':
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] }
case 'budget:updated':
return {
budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i),
}
case 'budget:deleted':
return {
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
}
case 'budget:members-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId ? { ...i, members: payload.members as BudgetMember[], persons: payload.persons as number } : i
),
}
case 'budget:member-paid-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
: i
),
}
// Reservations
case 'reservation:created':
if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {}
return { reservations: [payload.reservation as Reservation, ...state.reservations] }
case 'reservation:updated':
return {
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
}
case 'reservation:deleted':
return {
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
}
// Trip
case 'trip:updated':
return { trip: payload.trip as Trip }
// Files
case 'file:created':
if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {}
return { files: [payload.file as TripFile, ...state.files] }
case 'file:updated':
return {
files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f),
}
case 'file:deleted':
return {
files: state.files.filter(f => f.id !== payload.fileId),
}
default:
return {}
}
})
}

View File

@@ -0,0 +1,73 @@
import { reservationsApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Reservation } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface ReservationsSlice {
loadReservations: (tripId: number | string) => Promise<void>
addReservation: (tripId: number | string, data: Partial<Reservation>) => Promise<Reservation>
updateReservation: (tripId: number | string, id: number, data: Partial<Reservation>) => Promise<Reservation>
toggleReservationStatus: (tripId: number | string, id: number) => Promise<void>
deleteReservation: (tripId: number | string, id: number) => Promise<void>
}
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
loadReservations: async (tripId) => {
try {
const data = await reservationsApi.list(tripId)
set({ reservations: data.reservations })
} catch (err: unknown) {
console.error('Failed to load reservations:', err)
}
},
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error creating reservation'))
}
},
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
return result.reservation
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating reservation'))
}
},
toggleReservationStatus: async (tripId, id) => {
const prev = get().reservations
const current = prev.find(r => r.id === id)
if (!current) return
const newStatus: 'pending' | 'confirmed' = current.status === 'confirmed' ? 'pending' : 'confirmed'
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
})

View File

@@ -1,863 +0,0 @@
import { create } from 'zustand'
import { tripsApi, daysApi, placesApi, assignmentsApi, packingApi, tagsApi, categoriesApi, budgetApi, filesApi, reservationsApi, dayNotesApi } from '../api/client'
export const useTripStore = create((set, get) => ({
trip: null,
days: [],
places: [],
assignments: {}, // { [dayId]: [assignment objects] }
dayNotes: {}, // { [dayId]: [note objects] }
packingItems: [],
tags: [],
categories: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
isLoading: false,
error: null,
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
// Handle remote WebSocket events without making API calls
handleRemoteEvent: (event) => {
const { type, ...payload } = event
set(state => {
switch (type) {
// Places
case 'place:created':
if (state.places.some(p => p.id === payload.place.id)) return {}
return { places: [payload.place, ...state.places] }
case 'place:updated':
return {
places: state.places.map(p => p.id === payload.place.id ? payload.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === payload.place.id ? { ...a, place: payload.place } : a)
])
),
}
case 'place:deleted':
return {
places: state.places.filter(p => p.id !== payload.placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== payload.placeId)
])
),
}
// Assignments
case 'assignment:created': {
const dayKey = String(payload.assignment.day_id)
const existing = (state.assignments[dayKey] || [])
// Skip if already present (by id OR by place_id to handle optimistic temp ids)
const placeId = payload.assignment.place?.id || payload.assignment.place_id
if (existing.some(a => a.id === payload.assignment.id || (placeId && a.place?.id === placeId))) {
// Replace temp entry with server version if needed
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
if (hasTempVersion) {
return {
assignments: {
...state.assignments,
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment : a),
}
}
}
return {}
}
return {
assignments: {
...state.assignments,
[dayKey]: [...existing, payload.assignment],
}
}
}
case 'assignment:updated': {
const dayKey = String(payload.assignment.day_id)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).map(a =>
a.id === payload.assignment.id ? { ...a, ...payload.assignment } : a
),
}
}
}
case 'assignment:deleted': {
const dayKey = String(payload.dayId)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
}
}
}
case 'assignment:moved': {
const oldKey = String(payload.oldDayId)
const newKey = String(payload.newDayId)
const movedAssignment = payload.assignment
return {
assignments: {
...state.assignments,
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
}
}
}
case 'assignment:reordered': {
const dayKey = String(payload.dayId)
const currentItems = state.assignments[dayKey] || []
const orderedIds = payload.orderedIds || []
const reordered = orderedIds.map((id, idx) => {
const item = currentItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter(Boolean)
return {
assignments: {
...state.assignments,
[dayKey]: reordered,
}
}
}
// Days
case 'day:created':
if (state.days.some(d => d.id === payload.day.id)) return {}
return { days: [...state.days, payload.day] }
case 'day:updated':
return {
days: state.days.map(d => d.id === payload.day.id ? payload.day : d),
}
case 'day:deleted': {
const removedDayId = String(payload.dayId)
const newAssignments = { ...state.assignments }
delete newAssignments[removedDayId]
const newDayNotes = { ...state.dayNotes }
delete newDayNotes[removedDayId]
return {
days: state.days.filter(d => d.id !== payload.dayId),
assignments: newAssignments,
dayNotes: newDayNotes,
}
}
// Day Notes
case 'dayNote:created': {
const dayKey = String(payload.dayId)
const existingNotes = (state.dayNotes[dayKey] || [])
if (existingNotes.some(n => n.id === payload.note.id)) return {}
return {
dayNotes: {
...state.dayNotes,
[dayKey]: [...existingNotes, payload.note],
}
}
}
case 'dayNote:updated': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === payload.note.id ? payload.note : n),
}
}
}
case 'dayNote:deleted': {
const dayKey = String(payload.dayId)
return {
dayNotes: {
...state.dayNotes,
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
}
}
}
// Packing
case 'packing:created':
if (state.packingItems.some(i => i.id === payload.item.id)) return {}
return { packingItems: [...state.packingItems, payload.item] }
case 'packing:updated':
return {
packingItems: state.packingItems.map(i => i.id === payload.item.id ? payload.item : i),
}
case 'packing:deleted':
return {
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
}
// Budget
case 'budget:created':
if (state.budgetItems.some(i => i.id === payload.item.id)) return {}
return { budgetItems: [...state.budgetItems, payload.item] }
case 'budget:updated':
return {
budgetItems: state.budgetItems.map(i => i.id === payload.item.id ? payload.item : i),
}
case 'budget:deleted':
return {
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
}
case 'budget:members-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId ? { ...i, members: payload.members, persons: payload.persons } : i
),
}
case 'budget:member-paid-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
: i
),
}
// Reservations
case 'reservation:created':
if (state.reservations.some(r => r.id === payload.reservation.id)) return {}
return { reservations: [payload.reservation, ...state.reservations] }
case 'reservation:updated':
return {
reservations: state.reservations.map(r => r.id === payload.reservation.id ? payload.reservation : r),
}
case 'reservation:deleted':
return {
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
}
// Trip
case 'trip:updated':
return { trip: payload.trip }
// Files
case 'file:created':
if (state.files.some(f => f.id === payload.file.id)) return {}
return { files: [payload.file, ...state.files] }
case 'file:updated':
return {
files: state.files.map(f => f.id === payload.file.id ? payload.file : f),
}
case 'file:deleted':
return {
files: state.files.filter(f => f.id !== payload.fileId),
}
default:
return {}
}
})
},
// Load everything for a trip
loadTrip: async (tripId) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({
trip: tripData.trip,
days: daysData.days,
places: placesData.places,
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
})
} catch (err) {
set({ isLoading: false, error: err.message })
throw err
}
},
refreshDays: async (tripId) => {
try {
const daysData = await daysApi.list(tripId)
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
} catch (err) {
console.error('Failed to refresh days:', err)
}
},
refreshPlaces: async (tripId) => {
try {
const data = await placesApi.list(tripId)
set({ places: data.places })
} catch (err) {
console.error('Failed to refresh places:', err)
}
},
addPlace: async (tripId, placeData) => {
try {
const data = await placesApi.create(tripId, placeData)
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Error adding place')
}
},
updatePlace: async (tripId, placeId, placeData) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
])
),
}))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating place')
}
},
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== placeId)
])
),
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error deleting place')
}
},
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === parseInt(placeId))
if (!place) return
const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length
const tempAssignment = {
id: tempId,
day_id: parseInt(dayId),
order_index: insertIdx,
notes: null,
place,
}
current.splice(insertIdx, 0, tempAssignment)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: current,
}
}))
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment = {
...data.assignment,
place: data.assignment.place || place,
order_index: position != null ? insertIdx : data.assignment.order_index,
}
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].map(
a => a.id === tempId ? newAssignment : a
),
}
}))
// Reihenfolge am Server aktualisieren und lokalen State mit Server-Antwort synchronisieren
if (position != null) {
const updated = get().assignments[String(dayId)] || []
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
if (orderedIds.length > 0) {
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
// Lokalen State auf die gesendete Reihenfolge setzen
set(state => {
const items = state.assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = items.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter(Boolean)
return {
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}
})
} catch {}
}
}
return data.assignment
} catch (err) {
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
}
}))
throw new Error(err.response?.data?.error || 'Error assigning place')
}
},
removeAssignment: async (tripId, dayId, assignmentId) => {
const prevAssignments = get().assignments
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
}
}))
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Error removing assignment')
}
},
reorderAssignments: async (tripId, dayId, orderedIds) => {
const prevAssignments = get().assignments
const dayItems = get().assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = dayItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter(Boolean)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}))
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Error reordering')
}
},
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
const state = get()
const prevAssignments = state.assignments
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
if (!assignment) return
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
// Build new order for target day with item inserted at correct position
const newToItems = [...toItems]
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(toDayId) })
newToItems.forEach((a, i) => { a.order_index = i })
set(s => ({
assignments: {
...s.assignments,
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
[String(toDayId)]: newToItems,
}
}))
try {
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
if (newToItems.length > 1) {
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
}
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Error moving assignment')
}
},
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
const state = get()
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
if (!note) return
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
}
}))
try {
await dayNotesApi.delete(tripId, fromDayId, noteId)
const result = await dayNotesApi.create(tripId, toDayId, {
text: note.text, time: note.time, icon: note.icon, sort_order,
})
set(s => ({
dayNotes: {
...s.dayNotes,
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
}
}))
} catch (err) {
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
}
}))
throw new Error(err.response?.data?.error || 'Error moving note')
}
},
setAssignments: (assignments) => {
set({ assignments })
},
addPackingItem: async (tripId, data) => {
try {
const result = await packingApi.create(tripId, data)
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Error adding item')
}
},
updatePackingItem: async (tripId, id, data) => {
try {
const result = await packingApi.update(tripId, id, data)
set(state => ({
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating item')
}
},
deletePackingItem: async (tripId, id) => {
const prev = get().packingItems
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
try {
await packingApi.delete(tripId, id)
} catch (err) {
set({ packingItems: prev })
throw new Error(err.response?.data?.error || 'Error deleting item')
}
},
togglePackingItem: async (tripId, id, checked) => {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await packingApi.update(tripId, id, { checked })
} catch (err) {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating notes')
}
},
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating day name')
}
},
addTag: async (data) => {
try {
const result = await tagsApi.create(data)
set(state => ({ tags: [...state.tags, result.tag] }))
return result.tag
} catch (err) {
throw new Error(err.response?.data?.error || 'Error creating tag')
}
},
addCategory: async (data) => {
try {
const result = await categoriesApi.create(data)
set(state => ({ categories: [...state.categories, result.category] }))
return result.category
} catch (err) {
throw new Error(err.response?.data?.error || 'Error creating category')
}
},
updateTrip: async (tripId, data) => {
try {
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
const daysData = await daysApi.list(tripId)
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating trip')
}
},
loadBudgetItems: async (tripId) => {
try {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
} catch (err) {
console.error('Failed to load budget items:', err)
}
},
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Error adding budget item')
}
},
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating budget item')
}
},
deleteBudgetItem: async (tripId, id) => {
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
} catch (err) {
set({ budgetItems: prev })
throw new Error(err.response?.data?.error || 'Error deleting budget item')
}
},
setBudgetItemMembers: async (tripId, itemId, userIds) => {
const result = await budgetApi.setMembers(tripId, itemId, userIds);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
)
}));
return result;
},
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
await budgetApi.togglePaid(tripId, itemId, userId, paid);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
: item
)
}));
},
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
set({ files: data.files })
} catch (err) {
console.error('Failed to load files:', err)
}
},
addFile: async (tripId, formData) => {
try {
const data = await filesApi.upload(tripId, formData)
set(state => ({ files: [data.file, ...state.files] }))
return data.file
} catch (err) {
throw new Error(err.response?.data?.error || 'Error uploading file')
}
},
deleteFile: async (tripId, id) => {
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error deleting file')
}
},
loadReservations: async (tripId) => {
try {
const data = await reservationsApi.list(tripId)
set({ reservations: data.reservations })
} catch (err) {
console.error('Failed to load reservations:', err)
}
},
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Error creating reservation')
}
},
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating reservation')
}
},
toggleReservationStatus: async (tripId, id) => {
const prev = get().reservations
const current = prev.find(r => r.id === id)
if (!current) return
const newStatus = current.status === 'confirmed' ? 'pending' : 'confirmed'
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Error deleting reservation')
}
},
addDayNote: async (tripId, dayId, data) => {
const tempId = Date.now() * -1
const tempNote = { id: tempId, day_id: dayId, ...data, created_at: new Date().toISOString() }
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
}
}))
return result.note
} catch (err) {
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
}
}))
throw new Error(err.response?.data?.error || 'Error adding note')
}
},
updateDayNote: async (tripId, dayId, id, data) => {
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
}
}))
return result.note
} catch (err) {
throw new Error(err.response?.data?.error || 'Error updating note')
}
},
deleteDayNote: async (tripId, dayId, id) => {
const prev = get().dayNotes
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err) {
set({ dayNotes: prev })
throw new Error(err.response?.data?.error || 'Error deleting note')
}
},
}))

View File

@@ -0,0 +1,175 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, daysApi, placesApi, packingApi, tagsApi, categoriesApi } from '../api/client'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDayNotesSlice } from './slices/dayNotesSlice'
import { createPackingSlice } from './slices/packingSlice'
import { createBudgetSlice } from './slices/budgetSlice'
import { createReservationsSlice } from './slices/reservationsSlice'
import { createFilesSlice } from './slices/filesSlice'
import { handleRemoteEvent } from './slices/remoteEventHandler'
import type {
Trip, Day, Place, Assignment, DayNote, PackingItem,
Tag, Category, BudgetItem, TripFile, Reservation,
AssignmentsMap, DayNotesMap, WebSocketEvent,
} from '../types'
import { getApiErrorMessage } from '../types'
import type { PlacesSlice } from './slices/placesSlice'
import type { AssignmentsSlice } from './slices/assignmentsSlice'
import type { DayNotesSlice } from './slices/dayNotesSlice'
import type { PackingSlice } from './slices/packingSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
export interface TripStoreState
extends PlacesSlice,
AssignmentsSlice,
DayNotesSlice,
PackingSlice,
BudgetSlice,
ReservationsSlice,
FilesSlice {
trip: Trip | null
days: Day[]
places: Place[]
assignments: AssignmentsMap
dayNotes: DayNotesMap
packingItems: PackingItem[]
tags: Tag[]
categories: Category[]
budgetItems: BudgetItem[]
files: TripFile[]
reservations: Reservation[]
selectedDayId: number | null
isLoading: boolean
error: string | null
setSelectedDay: (dayId: number | null) => void
handleRemoteEvent: (event: WebSocketEvent) => void
loadTrip: (tripId: number | string) => Promise<void>
refreshDays: (tripId: number | string) => Promise<void>
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
addTag: (data: Partial<Tag>) => Promise<Tag>
addCategory: (data: Partial<Category>) => Promise<Category>
}
export const useTripStore = create<TripStoreState>((set, get) => ({
trip: null,
days: [],
places: [],
assignments: {},
dayNotes: {},
packingItems: [],
tags: [],
categories: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
isLoading: false,
error: null,
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({
trip: tripData.trip,
days: daysData.days,
places: placesData.places,
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
set({ isLoading: false, error: message })
throw err
}
},
refreshDays: async (tripId: number | string) => {
try {
const daysData = await daysApi.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
} catch (err: unknown) {
console.error('Failed to refresh days:', err)
}
},
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try {
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
const daysData = await daysApi.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
}
},
addTag: async (data: Partial<Tag>) => {
try {
const result = await tagsApi.create(data)
set((state) => ({ tags: [...state.tags, result.tag] }))
return result.tag
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error creating tag'))
}
},
addCategory: async (data: Partial<Category>) => {
try {
const result = await categoriesApi.create(data)
set((state) => ({ categories: [...state.categories, result.category] }))
return result.category
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error creating category'))
}
},
...createPlacesSlice(set, get),
...createAssignmentsSlice(set, get),
...createDayNotesSlice(set, get),
...createPackingSlice(set, get),
...createBudgetSlice(set, get),
...createReservationsSlice(set, get),
...createFilesSlice(set, get),
}))

Some files were not shown because too many files have changed in this diff Show More