- {res.reservation_time && (
-
-
{t('reservations.date')}
-
- {new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
+
+ {/* Reservation */}
+ {res && (() => {
+ const confirmed = res.status === 'confirmed'
+ return (
+
+
+
+
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+
{res.title}
+
+
+ {res.reservation_time && (
+
+
{t('reservations.date')}
+
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
-
- )}
- {res.reservation_time && (
-
-
{t('reservations.time')}
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
+ )}
+ {res.reservation_time && (
+
+
{t('reservations.time')}
+
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
-
- )}
- {res.confirmation_number && (
-
-
{t('reservations.confirmationCode')}
-
{res.confirmation_number}
-
- )}
- {res.location && (
-
-
{t('reservations.locationAddress')}
-
{res.location}
-
- )}
+ )}
+ {res.confirmation_number && (
+
+
{t('reservations.confirmationCode')}
+
{res.confirmation_number}
+
+ )}
+
+ {res.notes &&
{res.notes}
}
- {res.notes && (
-
{res.notes}
- )}
-
+ )
+ })()}
+
+ {/* Participants */}
+ {showParticipants && (
+
)}
)
})()}
- {/* Opening hours */}
+ {/* Opening hours + Files — side by side on desktop */}
+
{openingHours && openingHours.length > 0 && (
@@ -482,3 +491,116 @@ function ActionButton({ onClick, variant, icon, label }) {
)
}
+
+function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
+ const [showAdd, setShowAdd] = React.useState(false)
+ const [hoveredId, setHoveredId] = React.useState(null)
+
+ // Active participants: if allJoined, show all members; otherwise show only those in participantIds
+ const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id))
+ const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id))
+
+ const handleRemove = (userId) => {
+ if (!onSetParticipants) return
+ let newIds
+ if (allJoined) {
+ newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id)
+ } else {
+ newIds = participantIds.filter(id => id !== userId)
+ }
+ if (newIds.length === tripMembers.length) newIds = []
+ onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
+ }
+
+ const handleAdd = (userId) => {
+ if (!onSetParticipants) return
+ const newIds = [...participantIds, userId]
+ if (newIds.length === tripMembers.length) {
+ onSetParticipants(selectedAssignmentId, selectedDayId, [])
+ } else {
+ onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
+ }
+ setShowAdd(false)
+ }
+
+ return (
+
+
+ {t('inspector.participants')}
+
+
+ {activeMembers.map(member => {
+ const isHovered = hoveredId === member.id
+ const canRemove = activeMembers.length > 1
+ return (
+
setHoveredId(member.id)}
+ onMouseLeave={() => setHoveredId(null)}
+ onClick={() => { if (canRemove) handleRemove(member.id) }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99,
+ border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`,
+ background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)',
+ fontSize: 10, fontWeight: 500,
+ color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)',
+ cursor: canRemove ? 'pointer' : 'default',
+ transition: 'all 0.15s',
+ }}>
+
+ {member.avatar ?

: member.username?.[0]?.toUpperCase()}
+
+
{member.username}
+
+ )
+ })}
+
+ {/* Add button */}
+ {availableToAdd.length > 0 && (
+
+
+
+ {showAdd && (
+
+ {availableToAdd.map(member => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/client/src/components/shared/CustomTimePicker.jsx b/client/src/components/shared/CustomTimePicker.jsx
index 47bcc4a..1b0bc59 100644
--- a/client/src/components/shared/CustomTimePicker.jsx
+++ b/client/src/components/shared/CustomTimePicker.jsx
@@ -85,6 +85,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
+ } else if (/^\d{1,2}$/.test(clean)) {
+ const h = Math.min(23, Math.max(0, parseInt(clean)))
+ onChange(String(h).padStart(2, '0') + ':00')
}
}
diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js
index 7e82714..fe795bd 100644
--- a/client/src/i18n/translations/de.js
+++ b/client/src/i18n/translations/de.js
@@ -281,6 +281,12 @@ const de = {
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
+ // File Types
+ 'admin.fileTypes': 'Erlaubte Dateitypen',
+ 'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.',
+ 'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
+ 'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
+
// Addons
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
@@ -549,6 +555,7 @@ const de = {
'inspector.website': 'Webseite öffnen',
'inspector.addRes': 'Reservierung',
'inspector.editRes': 'Reservierung bearbeiten',
+ 'inspector.participants': 'Teilnehmer',
// Reservations
'reservations.title': 'Buchungen',
@@ -641,6 +648,7 @@ const de = {
'files.uploadError': 'Fehler beim Hochladen',
'files.dropzone': 'Dateien hier ablegen',
'files.dropzoneHint': 'oder klicken zum Auswählen',
+ 'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
'files.uploading': 'Wird hochgeladen...',
'files.filterAll': 'Alle',
'files.filterPdf': 'PDFs',
diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js
index d76c0a3..c6ae619 100644
--- a/client/src/i18n/translations/en.js
+++ b/client/src/i18n/translations/en.js
@@ -281,6 +281,12 @@ const en = {
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
'admin.oidcSaved': 'OIDC configuration saved',
+ // File Types
+ 'admin.fileTypes': 'Allowed File Types',
+ 'admin.fileTypesHint': 'Configure which file types users can upload.',
+ 'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
+ 'admin.fileTypesSaved': 'File type settings saved',
+
// Addons
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
@@ -549,6 +555,7 @@ const en = {
'inspector.website': 'Open Website',
'inspector.addRes': 'Reservation',
'inspector.editRes': 'Edit Reservation',
+ 'inspector.participants': 'Participants',
// Reservations
'reservations.title': 'Bookings',
@@ -641,6 +648,7 @@ const en = {
'files.uploadError': 'Upload failed',
'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse',
+ 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
'files.uploading': 'Uploading...',
'files.filterAll': 'All',
'files.filterPdf': 'PDFs',
diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx
index 117d632..ece53d8 100644
--- a/client/src/pages/AdminPage.jsx
+++ b/client/src/pages/AdminPage.jsx
@@ -43,6 +43,10 @@ export default function AdminPage() {
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(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)
+
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
@@ -91,6 +95,7 @@ export default function AdminPage() {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
+ if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err) {
// ignore
}
@@ -493,6 +498,39 @@ export default function AdminPage() {
diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx
index bc229f8..9acdc73 100644
--- a/client/src/pages/TripPlannerPage.jsx
+++ b/client/src/pages/TripPlannerPage.jsx
@@ -21,7 +21,7 @@ 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 } from '../api/client'
+import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
@@ -37,6 +37,8 @@ export default function TripPlannerPage() {
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
+ const [allowedFileTypes, setAllowedFileTypes] = useState(null)
+ const [tripMembers, setTripMembers] = useState([])
const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
@@ -48,6 +50,9 @@ export default function TripPlannerPage() {
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
}).catch(() => {})
+ authApi.getAppConfig().then(config => {
+ if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
+ }).catch(() => {})
}, [])
const TRIP_TABS = [
@@ -104,6 +109,11 @@ export default function TripPlannerPage() {
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripStore.loadFiles(tripId)
loadAccommodations()
+ tripsApi.getMembers(tripId).then(d => {
+ // Combine owner + members into one list
+ const all = [d.owner, ...(d.members || [])].filter(Boolean)
+ setTripMembers(all)
+ }).catch(() => {})
}
}, [tripId])
@@ -582,6 +592,20 @@ export default function TripPlannerPage() {
onRemoveAssignment={handleRemoveAssignment}
files={files}
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
+ tripMembers={tripMembers}
+ onSetParticipants={async (assignmentId, dayId, userIds) => {
+ try {
+ const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
+ useTripStore.setState(state => ({
+ assignments: {
+ ...state.assignments,
+ [String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
+ a.id === assignmentId ? { ...a, participants: data.participants } : a
+ ),
+ }
+ }))
+ } catch {}
+ }}
/>
)}
@@ -645,6 +669,7 @@ export default function TripPlannerPage() {
places={places}
reservations={reservations}
tripId={tripId}
+ allowedFileTypes={allowedFileTypes}
/>
)}
diff --git a/server/src/db/database.js b/server/src/db/database.js
index 13e500d..9cf8017 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -337,6 +337,14 @@ function initDb() {
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
+
+ CREATE TABLE IF NOT EXISTS assignment_participants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(assignment_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
`);
// Versioned migrations — each runs exactly once
@@ -438,6 +446,17 @@ function initDb() {
() => {
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
},
+ // 24: Assignment participants (who's joining which activity)
+ () => {
+ _db.exec(`
+ CREATE TABLE IF NOT EXISTS assignment_participants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(assignment_id, user_id)
+ )
+ `);
+ },
// Future migrations go here (append only, never reorder)
];
diff --git a/server/src/routes/assignments.js b/server/src/routes/assignments.js
index a7e0228..5ebbb67 100644
--- a/server/src/routes/assignments.js
+++ b/server/src/routes/assignments.js
@@ -30,11 +30,19 @@ function getAssignmentWithPlace(assignmentId) {
WHERE pt.place_id = ?
`).all(a.place_id);
+ const participants = db.prepare(`
+ SELECT ap.user_id, u.username, u.avatar
+ FROM assignment_participants ap
+ JOIN users u ON ap.user_id = u.id
+ WHERE ap.assignment_id = ?
+ `).all(a.id);
+
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
+ participants,
created_at: a.created_at,
place: {
id: a.place_id,
@@ -105,12 +113,25 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
}
}
+ // Load all participants for this day's assignments in one query
+ const assignmentIds = assignments.map(a => a.id)
+ const allParticipants = assignmentIds.length > 0
+ ? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
+ .all(...assignmentIds)
+ : []
+ const participantsByAssignment = {}
+ for (const p of allParticipants) {
+ if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
+ participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
+ }
+
const result = assignments.map(a => {
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
+ participants: participantsByAssignment[a.id] || [],
created_at: a.created_at,
place: {
id: a.place_id,
@@ -241,4 +262,45 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
});
+// GET /api/trips/:tripId/assignments/:id/participants
+router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+ if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const participants = db.prepare(`
+ SELECT ap.user_id, u.username, u.avatar
+ FROM assignment_participants ap
+ JOIN users u ON ap.user_id = u.id
+ WHERE ap.assignment_id = ?
+ `).all(id);
+
+ res.json({ participants });
+});
+
+// PUT /api/trips/:tripId/assignments/:id/participants — set participants (replace all)
+router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+ if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const { user_ids } = req.body; // array of user IDs, empty array = everyone
+ if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
+
+ // Delete existing and insert new
+ db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(id);
+ if (user_ids.length > 0) {
+ const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
+ for (const userId of user_ids) insert.run(id, userId);
+ }
+
+ const participants = db.prepare(`
+ SELECT ap.user_id, u.username, u.avatar
+ FROM assignment_participants ap
+ JOIN users u ON ap.user_id = u.id
+ WHERE ap.assignment_id = ?
+ `).all(id);
+
+ res.json({ participants });
+ broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id']);
+});
+
module.exports = router;
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index 07dce01..9f088cb 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -80,6 +80,7 @@ router.get('/app-config', (req, res) => {
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
+ allowed_file_types: db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get()?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@nomad.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
@@ -368,10 +369,13 @@ router.put('/app-settings', authenticate, (req, res) => {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
- const { allow_registration } = req.body;
+ const { allow_registration, allowed_file_types } = req.body;
if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
}
+ if (allowed_file_types !== undefined) {
+ db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
+ }
res.json({ success: true });
});
diff --git a/server/src/routes/days.js b/server/src/routes/days.js
index f6ca4e0..584d67f 100644
--- a/server/src/routes/days.js
+++ b/server/src/routes/days.js
@@ -117,6 +117,18 @@ router.get('/', authenticate, (req, res) => {
// Group assignments by day_id
const assignmentsByDayId = {};
+ // Load all participants for all assignments
+ const allAssignmentIds = allAssignments.map(a => a.id)
+ const allParticipants = allAssignmentIds.length > 0
+ ? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${allAssignmentIds.map(() => '?').join(',')})`)
+ .all(...allAssignmentIds)
+ : []
+ const participantsByAssignment = {}
+ for (const p of allParticipants) {
+ if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
+ participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
+ }
+
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push({
@@ -124,6 +136,7 @@ router.get('/', authenticate, (req, res) => {
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
+ participants: participantsByAssignment[a.id] || [],
created_at: a.created_at,
place: {
id: a.place_id,
diff --git a/server/src/routes/files.js b/server/src/routes/files.js
index a639be4..cf4c3fe 100644
--- a/server/src/routes/files.js
+++ b/server/src/routes/files.js
@@ -22,25 +22,27 @@ const storage = multer.diskStorage({
},
});
+const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
+const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
+
+function getAllowedExtensions() {
+ try {
+ const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get();
+ return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
+ } catch { return DEFAULT_ALLOWED_EXTENSIONS; }
+}
+
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => {
- const allowed = [
- 'application/pdf',
- 'application/msword',
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- 'application/vnd.ms-excel',
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- 'text/plain',
- 'text/csv',
- ];
const ext = path.extname(file.originalname).toLowerCase();
- const blockedExts = ['.svg', '.html', '.htm', '.xml'];
- if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
+ if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
return cb(new Error('File type not allowed'));
}
- if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
+ const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
+ const fileExt = ext.replace('.', '');
+ if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
cb(null, true);
} else {
cb(new Error('File type not allowed'));