mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 068b90ed72 | |||
| 17288f9a0e | |||
| 3bf49d4180 | |||
| 66e2799870 | |||
| 732accce3d | |||
| 785e8264cd | |||
| e3cb5745dd | |||
| 785f0a7684 | |||
| e1cd9655fb | |||
| 2e0481c045 | |||
| 3d13ed75d7 | |||
| 7094e54432 | |||
| 858bea1952 | |||
| 3fd2410ba6 | |||
| c1e568cb1e | |||
| 21a71697be | |||
| e660cca284 | |||
| 763c878dab | |||
| d0d39d1e35 | |||
| e70cd5729e | |||
| 114ec7d131 |
@@ -131,15 +131,23 @@ docker compose up -d
|
||||
|
||||
### Updating
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/nomad
|
||||
docker rm -f nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
```
|
||||
|
||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes.
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
@@ -212,12 +220,6 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
||||
3. Create an API key under Credentials
|
||||
4. In NOMAD: Admin Panel → Settings → Google Maps
|
||||
|
||||
### OpenWeatherMap (Weather Forecasts)
|
||||
|
||||
1. Sign up at [OpenWeatherMap](https://openweathermap.org/api)
|
||||
2. Get a free API key
|
||||
3. In NOMAD: Admin Panel → Settings → OpenWeatherMap
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.7",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -95,6 +95,9 @@ export const assignmentsApi = {
|
||||
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 = {
|
||||
@@ -149,6 +152,9 @@ export const budgetApi = {
|
||||
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 = {
|
||||
@@ -192,6 +198,28 @@ export const dayNotesApi = {
|
||||
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),
|
||||
|
||||
@@ -118,8 +118,9 @@ export default function AddonManager() {
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }) {
|
||||
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)' }}>
|
||||
<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' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
@@ -129,6 +130,11 @@ function AddonRow({ addon, onToggle, t }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||
color: 'var(--text-muted)',
|
||||
@@ -141,19 +147,20 @@ function AddonRow({ addon, onToggle, t }) {
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onToggle(addon)}
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
|
||||
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
@@ -110,6 +112,172 @@ function AddItemRow({ onAdd, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-Person Inline (inside total card) ────────────────────────────────────
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map(person => (
|
||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{person.avatar_url
|
||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: person.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
function PieChart({ segments, size = 200, totalLabel }) {
|
||||
if (!segments.length) return null
|
||||
@@ -148,13 +316,15 @@ function PieChart({ segments, size = 200, totalLabel }) {
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function BudgetPanel({ tripId }) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
@@ -187,6 +357,11 @@ export default function BudgetPanel({ tripId }) {
|
||||
const items = grouped[cat] || []
|
||||
for (const item of 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() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||
@@ -239,9 +414,27 @@ export default function BudgetPanel({ tripId }) {
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
@@ -258,8 +451,8 @@ export default function BudgetPanel({ tripId }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
|
||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
@@ -273,16 +466,38 @@ export default function BudgetPanel({ tripId }) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
const hasMembers = item.members?.length > 0
|
||||
return (
|
||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
@@ -351,6 +566,9 @@ export default function BudgetPanel({ tripId }) {
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
@@ -358,6 +576,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
|
||||
@@ -386,6 +605,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
function MessageText({ text }) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
const previewCache = {}
|
||||
|
||||
function LinkPreview({ url, tripId, own, onLoad }) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
function ReactionBadge({ reaction, currentUserId, onReact }) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
const names = reaction.users.map(u => u.username).join(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main Component ── */
|
||||
export default function CollabChat({ tripId, currentUser }) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
||||
}, [])
|
||||
|
||||
const checkAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
}, [])
|
||||
|
||||
/* ── load messages ── */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getMessages(tripId).then(data => {
|
||||
if (cancelled) return
|
||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
setMessages(msgs)
|
||||
setHasMore(msgs.length >= 100)
|
||||
setLoading(false)
|
||||
setTimeout(() => scrollToBottom(), 30)
|
||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loadingMore || messages.length === 0) return
|
||||
setLoadingMore(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el ? el.scrollHeight : 0
|
||||
try {
|
||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
if (older.length === 0) { setHasMore(false) }
|
||||
else {
|
||||
setMessages(prev => [...older, ...prev])
|
||||
setHasMore(older.length >= 100)
|
||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
||||
}
|
||||
} catch {} finally { setLoadingMore(false) }
|
||||
}, [tripId, loadingMore, messages])
|
||||
|
||||
/* ── websocket ── */
|
||||
useEffect(() => {
|
||||
const handler = (event) => {
|
||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
||||
}
|
||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
}
|
||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
setText(e.target.value)
|
||||
const ta = textareaRef.current
|
||||
if (ta) {
|
||||
ta.style.height = 'auto'
|
||||
const h = Math.min(ta.scrollHeight, 100)
|
||||
ta.style.height = h + 'px'
|
||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch {} finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}, [handleSend])
|
||||
|
||||
const handleDelete = useCallback(async (msgId) => {
|
||||
const msg = messages.find(m => m.id === msgId)
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
setReactMenu(null)
|
||||
try {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
||||
|
||||
// Check if message is only emoji (1-3 emojis, no other text)
|
||||
const isEmojiOnly = (text) => {
|
||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
/* ── Loading ── */
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const own = isOwn(msg)
|
||||
const prevMsg = messages[idx - 1]
|
||||
const nextMsg = messages[idx + 1]
|
||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||
const showAvatar = !own && isLastInGroup
|
||||
const bigEmoji = isEmojiOnly(msg.text)
|
||||
const hasReply = msg.reply_text || msg.reply_to
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
e.currentTarget.dataset.lastTap = now
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
||||
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
|
||||
</span>
|
||||
<button onClick={() => setReplyTo(null)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
|
||||
display: 'flex', flexShrink: 0,
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji picker */}
|
||||
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
|
||||
|
||||
{/* Reaction quick menu (right-click) */}
|
||||
{reactMenu && ReactDOM.createPortal(
|
||||
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
useEffect(() => {
|
||||
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [breakpoint])
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||
|
||||
function timeRemaining(deadline) {
|
||||
if (!deadline) return null
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
if (diff <= 0) return null
|
||||
const mins = Math.floor(diff / 60000)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days > 0) return `${days}d ${hrs % 24}h`
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function isExpired(deadline) {
|
||||
if (!deadline) return false
|
||||
return new Date(deadline).getTime() <= Date.now()
|
||||
}
|
||||
|
||||
function totalVotes(poll) {
|
||||
return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0)
|
||||
}
|
||||
|
||||
// ── Create Poll Modal ────────────────────────────────────────────────────────
|
||||
function CreatePollModal({ onClose, onCreate, t }) {
|
||||
const [question, setQuestion] = useState('')
|
||||
const [options, setOptions] = useState(['', ''])
|
||||
const [multiChoice, setMultiChoice] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const addOption = () => setOptions(prev => [...prev, ''])
|
||||
const removeOption = (i) => setOptions(prev => prev.filter((_, j) => j !== i))
|
||||
const updateOption = (i, v) => setOptions(prev => prev.map((o, j) => j === i ? v : o))
|
||||
|
||||
const canSubmit = question.trim() && options.filter(o => o.trim()).length >= 2 && !submitting
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
|
||||
onClose()
|
||||
} catch {} finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
|
||||
<Plus size={12} /> {t('collab.polls.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi choice toggle */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<div onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
|
||||
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={!canSubmit} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{submitting ? '...' : t('collab.polls.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
|
||||
function VoterChip({ voter, offset }) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const ref = React.useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{voter.username}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Poll Card ────────────────────────────────────────────────────────────────
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 14, border: '1px solid var(--border-faint)', overflow: 'hidden',
|
||||
background: 'var(--bg-card)', fontFamily: FONT,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Lock size={8} /> {t('collab.polls.closed')}
|
||||
</span>
|
||||
)}
|
||||
{remaining && !isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multiple_choice && (
|
||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
|
||||
{total} {total === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(poll.options || []).map((opt, idx) => {
|
||||
const count = opt.voters?.length || 0
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
||||
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
|
||||
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
|
||||
|
||||
return (
|
||||
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0,
|
||||
width: `${pct}%`, borderRadius: 10,
|
||||
background: myVote ? '#007AFF20' : isWinner ? '#10b98118' : 'var(--bg-tertiary)',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
|
||||
{/* Check circle */}
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, position: 'relative',
|
||||
border: myVote ? '2px solid #007AFF' : '2px solid var(--border-primary)',
|
||||
background: myVote ? '#007AFF' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.label || opt}
|
||||
</span>
|
||||
|
||||
{/* Voter avatars */}
|
||||
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
|
||||
<div style={{ display: 'flex', position: 'relative', zIndex: 1 }}>
|
||||
{(opt.voters || []).slice(0, 3).map((v, vi) => (
|
||||
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
|
||||
}}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function CollabPolls({ tripId, currentUser }) {
|
||||
const { t } = useTranslation()
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
collabApi.getPolls(tripId).then(data => {
|
||||
setPolls(Array.isArray(data) ? data : data.polls || [])
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket
|
||||
useEffect(() => {
|
||||
const handler = (msg) => {
|
||||
if (!msg?.type) return
|
||||
if (msg.type === 'collab:poll:created' && msg.poll) {
|
||||
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:poll:voted' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? msg.poll : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:closed' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:deleted') {
|
||||
const id = msg.pollId || msg.poll?.id
|
||||
if (id) setPolls(prev => prev.filter(p => p.id !== id))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
}, [tripId])
|
||||
|
||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||
try {
|
||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||
const updated = result.poll || result
|
||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleClose = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.closePoll(tripId, pollId)
|
||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleDelete = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
||||
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
||||
|
||||
// Deadline ticker
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!polls.some(p => p.deadline && !p.is_closed)) return
|
||||
const iv = setInterval(() => setTick(t => t + 1), 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [polls])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'collab-poll-spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes collab-poll-spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{polls.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
|
||||
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showForm && <CreatePollModal onClose={() => setShowForm(false)} onCreate={handleCreate} t={t} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }) {
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const items = []
|
||||
|
||||
for (const day of (days || [])) {
|
||||
if (!day.date) continue
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
// Include: today (future times) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
category: a.place.category,
|
||||
participants: (a.participants && a.participants.length > 0)
|
||||
? a.participants
|
||||
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
||||
address: a.place.address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const da = a.date + (a.time || '99:99')
|
||||
const db = b.date + (b.time || '99:99')
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
const prevItem = upcoming[idx - 1]
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
>
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
||||
{item.participants.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -77,7 +77,7 @@ function SourceBadge({ icon: Icon, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
@@ -128,6 +128,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||
if (filterType === 'image') return isImage(f.mime_type)
|
||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
||||
if (filterType === 'collab') return !!f.note_id
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -229,6 +230,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,6 +244,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
@@ -270,7 +275,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const fileUrl = file.url || `/uploads/files/${file.filename}`
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
@@ -293,7 +298,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -322,6 +335,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
|
||||
/>
|
||||
)}
|
||||
{file.note_id && (
|
||||
<SourceBadge
|
||||
icon={StickyNote}
|
||||
label={t('files.sourceCollab') || 'Collab Notes'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file.description && !linkedReservation && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
@@ -158,6 +159,50 @@ function MapClickHandler({ onClick }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const check = () => setVisible(map.getZoom() >= 16)
|
||||
check()
|
||||
map.on('zoomend', check)
|
||||
return () => map.off('zoomend', check)
|
||||
}, [map])
|
||||
|
||||
if (!visible || !midpoint) return null
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
html: `<div style="
|
||||
display:flex;align-items:center;gap:5px;
|
||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
||||
color:#fff;border-radius:99px;padding:3px 9px;
|
||||
font-size:9px;font-weight:600;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
||||
pointer-events:none;
|
||||
position:relative;left:-50%;top:-50%;
|
||||
">
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
||||
${walkingText}
|
||||
</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
||||
${drivingText}
|
||||
</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
|
||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
|
||||
@@ -165,6 +210,7 @@ export function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -297,13 +343,18 @@ export function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
)
|
||||
|
||||
@@ -41,12 +41,17 @@ export async function calculateRoute(waypoints, profile = 'driving') {
|
||||
duration = route.duration // driving: use OSRM value
|
||||
}
|
||||
|
||||
const walkingDuration = distance / (5000 / 3600) // 5 km/h
|
||||
const drivingDuration = route.duration // OSRM driving value
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distance,
|
||||
duration,
|
||||
distanceText: formatDistance(distance),
|
||||
durationText: formatDuration(duration),
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(drivingDuration),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -447,17 +447,8 @@ export default function PlaceFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time & Reservation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.place_time}
|
||||
onChange={e => update('place_time', 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"
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
|
||||
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(place)}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '' })
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||
@@ -71,10 +71,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
|
||||
|
||||
const handleSetAccommodation = async (placeId) => {
|
||||
const handleSelectPlace = (placeId) => {
|
||||
setHotelForm(f => ({ ...f, place_id: placeId }))
|
||||
}
|
||||
|
||||
const handleSaveAccommodation = async () => {
|
||||
if (!hotelForm.place_id) return
|
||||
try {
|
||||
const data = await accommodationsApi.create(tripId, {
|
||||
place_id: placeId,
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
@@ -84,7 +89,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
setAccommodation(data.accommodation)
|
||||
setAccommodations(prev => [...prev, data.accommodation])
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '' })
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
@@ -309,7 +314,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '' }); setShowHotelPicker('edit') }}
|
||||
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
@@ -343,8 +348,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day Range (hidden in edit mode) */}
|
||||
{showHotelPicker !== 'edit' && <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
||||
{/* Day Range */}
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -378,7 +383,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
{t('day.allDays')}
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Check-in / Check-out / Confirmation */}
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
@@ -397,23 +402,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit mode: save button instead of place list */}
|
||||
{showHotelPicker === 'edit' ? (
|
||||
<div style={{ padding: '14px 18px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button onClick={async () => {
|
||||
await updateAccommodationField('check_in', hotelForm.check_in)
|
||||
await updateAccommodationField('check_out', hotelForm.check_out)
|
||||
await updateAccommodationField('confirmation', hotelForm.confirmation)
|
||||
setShowHotelPicker(false)
|
||||
}} style={{
|
||||
padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
}}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
) : <>
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -440,14 +428,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
return filtered.length === 0 ? (
|
||||
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
|
||||
) : filtered.map(p => (
|
||||
<button key={p.id} onClick={() => handleSetAccommodation(p.id)} style={{
|
||||
<button key={p.id} onClick={() => handleSelectPlace(p.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
|
||||
border: 'none', borderBottom: '1px solid var(--border-faint)', background: 'none',
|
||||
border: 'none', borderBottom: '1px solid var(--border-faint)',
|
||||
background: hotelForm.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
outline: hotelForm.place_id === p.id ? '2px solid var(--accent)' : 'none',
|
||||
outlineOffset: -2, borderRadius: hotelForm.place_id === p.id ? 8 : 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onMouseEnter={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{p.image_url ? (
|
||||
@@ -464,7 +455,44 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Save / Cancel */}
|
||||
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
if (showHotelPicker === 'edit' && accommodation) {
|
||||
// Update existing
|
||||
await accommodationsApi.update(tripId, accommodation.id, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
setAccommodations(d.accommodations || [])
|
||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
} else {
|
||||
await handleSaveAccommodation()
|
||||
}
|
||||
}} disabled={!hotelForm.place_id} style={{
|
||||
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
|
||||
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
|
||||
}}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -6,6 +6,7 @@ const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Tr
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -23,7 +24,10 @@ function formatDate(dateStr, locale) {
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
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
|
||||
@@ -76,18 +80,25 @@ export default function DayPlanSidebar({
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
}) {
|
||||
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 [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set(JSON.parse(saved))
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
})
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
@@ -123,9 +134,20 @@ export default function DayPlanSidebar({
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
const prevDayCount = React.useRef(days.length)
|
||||
useEffect(() => {
|
||||
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
|
||||
}, [days.length])
|
||||
if (days.length > prevDayCount.current) {
|
||||
// New days added — expand only those
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
prevDayCount.current = days.length
|
||||
}, [days.length, tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingDayId && inputRef.current) inputRef.current.focus()
|
||||
@@ -149,6 +171,7 @@ export default function DayPlanSidebar({
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -444,7 +467,7 @@ export default function DayPlanSidebar({
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
onClick={() => { onSelectDay(isSelected ? null : day.id); if (onDayDetail) onDayDetail(isSelected ? null : day) }}
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
@@ -452,7 +475,7 @@ export default function DayPlanSidebar({
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 14px 11px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
|
||||
transition: 'background 0.12s',
|
||||
userSelect: 'none',
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
@@ -645,6 +668,14 @@ export default function DayPlanSidebar({
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -739,6 +770,23 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{assignment.participants?.length > 0 && (
|
||||
<div style={{ marginTop: 3, display: 'flex', alignItems: 'center', gap: -4 }}>
|
||||
{assignment.participants.slice(0, 5).map((p, pi) => (
|
||||
<div key={p.user_id} style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)', border: '1.5px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{assignment.participants.length > 5 && (
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', marginLeft: 2 }}>+{assignment.participants.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
@@ -787,6 +835,11 @@ export default function DayPlanSidebar({
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -807,12 +860,11 @@ export default function DayPlanSidebar({
|
||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
|
||||
{note.text}
|
||||
</span>
|
||||
{note.time && (
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
@@ -934,14 +986,16 @@ export default function DayPlanSidebar({
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
value={ui.time}
|
||||
maxLength={150}
|
||||
rows={3}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteSubtitle')}
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||
@@ -960,6 +1014,7 @@ export default function DayPlanSidebar({
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X } from 'lucide-react'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
|
||||
@@ -24,7 +24,7 @@ const DEFAULT_FORM = {
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen, onClose, onSave, place, tripId, categories,
|
||||
onCategoryCreated,
|
||||
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||
}) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
@@ -126,6 +126,8 @@ export default function PlaceFormModal({
|
||||
}
|
||||
}
|
||||
|
||||
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) {
|
||||
@@ -293,23 +295,17 @@ export default function PlaceFormModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={v => handleChange('place_time', v)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.end_time}
|
||||
onChange={v => handleChange('end_time', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Time — only shown when editing, not when creating */}
|
||||
{place && (
|
||||
<TimeSection
|
||||
form={form}
|
||||
handleChange={handleChange}
|
||||
assignmentId={assignmentId}
|
||||
dayAssignments={dayAssignments}
|
||||
hasTimeError={hasTimeError}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
@@ -364,7 +360,7 @@ export default function PlaceFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || hasTimeError}
|
||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||
>
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||
@@ -374,3 +370,62 @@ export default function PlaceFormModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
|
||||
|
||||
const collisions = useMemo(() => {
|
||||
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
||||
// Find the day_id for the current assignment
|
||||
const current = dayAssignments.find(a => a.id === assignmentId)
|
||||
if (!current) return []
|
||||
const myStart = form.place_time
|
||||
const myEnd = form.end_time && form.end_time.length >= 5 ? form.end_time : null
|
||||
return dayAssignments.filter(a => {
|
||||
if (a.id === assignmentId) return false
|
||||
if (a.day_id !== current.day_id) return false
|
||||
const aStart = a.place?.place_time
|
||||
const aEnd = a.place?.end_time
|
||||
if (!aStart) return false
|
||||
// Check overlap: two intervals overlap if start < otherEnd AND otherStart < end
|
||||
const s1 = myStart, e1 = myEnd || myStart
|
||||
const s2 = aStart, e2 = aEnd || aStart
|
||||
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
|
||||
})
|
||||
}, [assignmentId, dayAssignments, form.place_time, form.end_time])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={v => handleChange('place_time', v)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.end_time}
|
||||
onChange={v => handleChange('end_time', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasTimeError && (
|
||||
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
{t('places.endTimeBeforeStart')}
|
||||
</div>
|
||||
)}
|
||||
{collisions.length > 0 && (
|
||||
<div className="flex items-start gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
||||
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t('places.timeCollision')}{' '}
|
||||
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -75,7 +75,10 @@ function convertHoursLine(line, timeFormat) {
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
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
|
||||
@@ -97,7 +100,7 @@ function formatFileSize(bytes) {
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload,
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants,
|
||||
}) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
@@ -202,7 +205,7 @@ export default function PlaceInspector({
|
||||
padding: '2px 8px', borderRadius: 99,
|
||||
}}>
|
||||
<CatIcon size={10} />
|
||||
{category.name}
|
||||
<span className="hidden sm:inline">{category.name}</span>
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
@@ -210,17 +213,17 @@ export default function PlaceInspector({
|
||||
{place.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
||||
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.place_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
|
||||
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.lat && place.lng && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
@@ -238,8 +241,8 @@ export default function PlaceInspector({
|
||||
{/* Content — scrollable */}
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{googleDetails?.rating && (() => {
|
||||
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
||||
return (
|
||||
@@ -278,64 +281,71 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation for this specific assignment */}
|
||||
{/* Reservation + Participants — side by side */}
|
||||
{(() => {
|
||||
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const accentColor = confirmed ? '#16a34a' : '#d97706'
|
||||
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||
const currentParticipants = assignment?.participants || []
|
||||
const participantIds = currentParticipants.map(p => p.user_id)
|
||||
const allJoined = currentParticipants.length === 0
|
||||
const showParticipants = selectedAssignmentId && tripMembers.length > 1
|
||||
if (!res && !showParticipants) return null
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: accentColor }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: accentColor }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</span>
|
||||
</div>
|
||||
{/* Details grid */}
|
||||
{(res.reservation_time || res.confirmation_number || res.location || res.notes) && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Reservation */}
|
||||
{res && (() => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
)}
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-muted)', marginTop: 1 }}>{res.location}</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||
</div>
|
||||
{res.notes && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', lineHeight: 1.4, borderTop: '1px solid var(--border-faint)', paddingTop: 5 }}>{res.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Participants */}
|
||||
{showParticipants && (
|
||||
<ParticipantsBox
|
||||
tripMembers={tripMembers}
|
||||
participantIds={participantIds}
|
||||
allJoined={allJoined}
|
||||
onSetParticipants={onSetParticipants}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
selectedDayId={selectedDayId}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Opening hours */}
|
||||
{/* Opening hours + Files — side by side on desktop only if both exist */}
|
||||
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
||||
{openingHours && openingHours.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<button
|
||||
@@ -397,24 +407,17 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
<a
|
||||
href={`/uploads/files/${f.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -487,3 +490,116 @@ function ActionButton({ onClick, variant, icon, label }) {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ borderRadius: 12, border: '1px solid var(--border-faint)', padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Users size={10} /> {t('inspector.participants')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }}>
|
||||
{activeMembers.map(member => {
|
||||
const isHovered = hoveredId === member.id
|
||||
const canRemove = activeMembers.length > 1
|
||||
return (
|
||||
<div key={member.id}
|
||||
onMouseEnter={() => 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',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add button */}
|
||||
{availableToAdd.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowAdd(!showAdd)} style={{
|
||||
width: 22, height: 22, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', fontSize: 12, transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>+</button>
|
||||
|
||||
{showAdd && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 26, left: 0, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 140,
|
||||
}}>
|
||||
{availableToAdd.map(member => (
|
||||
<button key={member.id} onClick={() => handleAdd(member.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{member.username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays } from 'lucide-react'
|
||||
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'
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
@@ -138,6 +140,14 @@ export default function PlacesSidebar({
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
@@ -237,6 +247,7 @@ export default function PlacesSidebar({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)}
|
||||
{/* Row 2: Location + Assignment */}
|
||||
{(r.location || linked) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: r.location && linked ? '1fr 1fr' : '1fr', gap: 8, paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||
@@ -224,16 +224,6 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
{showHint && (
|
||||
<div style={{ margin: '12px 24px 4px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Lightbulb size={12} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>{t('reservations.placeHint')}</p>
|
||||
<button onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 14, lineHeight: 1, flexShrink: 0 }}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{total === 0 ? (
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
||||
disabled={adding || !selectedUserId}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
|
||||
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState(null) // { x, y, items }
|
||||
|
||||
const open = (e, items) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}
|
||||
|
||||
const close = () => setMenu(null)
|
||||
|
||||
return { menu, open, close }
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
const handler = () => onClose()
|
||||
document.addEventListener('click', handler)
|
||||
document.addEventListener('contextmenu', handler)
|
||||
return () => {
|
||||
document.removeEventListener('click', handler)
|
||||
document.removeEventListener('contextmenu', handler)
|
||||
}
|
||||
}, [menu, onClose])
|
||||
|
||||
// Adjust position if menu would overflow viewport
|
||||
useEffect(() => {
|
||||
if (!menu || !ref.current) return
|
||||
const el = ref.current
|
||||
const rect = el.getBoundingClientRect()
|
||||
let { x, y } = menu
|
||||
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
|
||||
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
|
||||
if (x !== menu.x || y !== menu.y) {
|
||||
el.style.left = `${x}px`
|
||||
el.style.top = `${y}px`
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
minWidth: 160,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
animation: 'ctxIn 0.1s ease-out',
|
||||
}}>
|
||||
{menu.items.filter(Boolean).map((item, i) => {
|
||||
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={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', borderRadius: 7, border: 'none',
|
||||
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 12, fontWeight: 500, textAlign: 'left',
|
||||
color: item.danger ? '#ef4444' : 'var(--text-primary)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!place.google_place_id) return
|
||||
if (!place.google_place_id) { setPhotoSrc(null); return }
|
||||
|
||||
if (googlePhotoCache.has(place.google_place_id)) {
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
|
||||
|
||||
@@ -127,6 +127,9 @@ const de = {
|
||||
'settings.language': 'Sprache',
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.on': 'An',
|
||||
'settings.off': 'Aus',
|
||||
'settings.account': 'Konto',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
@@ -281,6 +284,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',
|
||||
@@ -498,7 +507,7 @@ const de = {
|
||||
'dayplan.pdfError': 'Fehler beim PDF-Export',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort hinzufügen',
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
@@ -523,6 +532,8 @@ const de = {
|
||||
'places.formTime': 'Uhrzeit',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'Ende',
|
||||
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
|
||||
'places.timeCollision': 'Zeitliche Überschneidung mit:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||
'places.formReservation': 'Reservierung',
|
||||
@@ -549,6 +560,7 @@ const de = {
|
||||
'inspector.website': 'Webseite öffnen',
|
||||
'inspector.addRes': 'Reservierung',
|
||||
'inspector.editRes': 'Reservierung bearbeiten',
|
||||
'inspector.participants': 'Teilnehmer',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Buchungen',
|
||||
@@ -621,7 +633,7 @@ const de = {
|
||||
'budget.table.days': 'Tage',
|
||||
'budget.table.perPerson': 'Pro Person',
|
||||
'budget.table.perDay': 'Pro Tag',
|
||||
'budget.table.perPersonDay': 'Pro Person/Tag',
|
||||
'budget.table.perPersonDay': 'P. p / Tag',
|
||||
'budget.table.note': 'Notiz',
|
||||
'budget.newEntry': 'Neuer Eintrag',
|
||||
'budget.defaultEntry': 'Neuer Eintrag',
|
||||
@@ -632,6 +644,10 @@ const de = {
|
||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||
'budget.deleteCategory': 'Kategorie löschen',
|
||||
'budget.perPerson': 'Pro Person',
|
||||
'budget.paid': 'Bezahlt',
|
||||
'budget.open': 'Offen',
|
||||
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
||||
|
||||
// Files
|
||||
'files.title': 'Dateien',
|
||||
@@ -641,11 +657,14 @@ 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',
|
||||
'files.filterImages': 'Bilder',
|
||||
'files.filterDocs': 'Dokumente',
|
||||
'files.filterCollab': 'Collab Notizen',
|
||||
'files.sourceCollab': 'Aus Collab Notizen',
|
||||
'files.empty': 'Keine Dateien vorhanden',
|
||||
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
|
||||
'files.openTab': 'In neuem Tab öffnen',
|
||||
@@ -856,8 +875,8 @@ const de = {
|
||||
'planner.placeN': '{n} Orte',
|
||||
'planner.addNote': 'Notiz hinzufügen',
|
||||
'planner.noEntries': 'Keine Einträge für diesen Tag',
|
||||
'planner.addPlace': 'Ort hinzufügen',
|
||||
'planner.addPlaceShort': '+ Ort hinzufügen',
|
||||
'planner.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'planner.addPlaceShort': '+ Ort/Aktivität hinzufügen',
|
||||
'planner.resPending': 'Reservierung ausstehend · ',
|
||||
'planner.resConfirmed': 'Reservierung bestätigt · ',
|
||||
'planner.notePlaceholder': 'Notiz\u2026',
|
||||
@@ -911,6 +930,7 @@ const de = {
|
||||
'day.hourlyForecast': 'Stündliche Vorhersage',
|
||||
'day.climateHint': 'Historische Durchschnittswerte — echte Vorhersage verfügbar innerhalb von 16 Tagen vor diesem Datum.',
|
||||
'day.noWeather': 'Keine Wetterdaten verfügbar. Füge einen Ort mit Koordinaten hinzu.',
|
||||
'day.overview': 'Tagesübersicht',
|
||||
'day.accommodation': 'Unterkunft',
|
||||
'day.addAccommodation': 'Unterkunft hinzufügen',
|
||||
'day.hotelDayRange': 'Auf Tage anwenden',
|
||||
@@ -921,6 +941,75 @@ const de = {
|
||||
'day.confirmation': 'Bestätigung',
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
'day.reservations': 'Reservierungen',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notizen',
|
||||
'collab.tabs.polls': 'Umfragen',
|
||||
'collab.whatsNext.title': 'Nächste',
|
||||
'collab.whatsNext.today': 'Heute',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
'collab.whatsNext.empty': 'Keine anstehenden Aktivitäten',
|
||||
'collab.whatsNext.until': 'bis',
|
||||
'collab.whatsNext.emptyHint': 'Aktivitäten mit Uhrzeit erscheinen hier',
|
||||
'collab.chat.send': 'Senden',
|
||||
'collab.chat.placeholder': 'Nachricht eingeben...',
|
||||
'collab.chat.empty': 'Starte die Unterhaltung',
|
||||
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
|
||||
'collab.chat.today': 'Heute',
|
||||
'collab.chat.yesterday': 'Gestern',
|
||||
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
||||
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||
'collab.chat.justNow': 'gerade eben',
|
||||
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||
'collab.chat.hoursAgo': 'vor {n} Std.',
|
||||
'collab.chat.yesterday': 'gestern',
|
||||
'collab.notes.title': 'Notizen',
|
||||
'collab.notes.new': 'Neue Notiz',
|
||||
'collab.notes.empty': 'Noch keine Notizen',
|
||||
'collab.notes.emptyHint': 'Halte Ideen und Pläne fest',
|
||||
'collab.notes.all': 'Alle',
|
||||
'collab.notes.titlePlaceholder': 'Notiztitel',
|
||||
'collab.notes.contentPlaceholder': 'Schreibe etwas...',
|
||||
'collab.notes.categoryPlaceholder': 'Kategorie',
|
||||
'collab.notes.newCategory': 'Neue Kategorie...',
|
||||
'collab.notes.category': 'Kategorie',
|
||||
'collab.notes.noCategory': 'Keine Kategorie',
|
||||
'collab.notes.color': 'Farbe',
|
||||
'collab.notes.save': 'Speichern',
|
||||
'collab.notes.cancel': 'Abbrechen',
|
||||
'collab.notes.edit': 'Bearbeiten',
|
||||
'collab.notes.delete': 'Löschen',
|
||||
'collab.notes.pin': 'Anheften',
|
||||
'collab.notes.unpin': 'Loslösen',
|
||||
'collab.notes.daysAgo': 'vor {n} T.',
|
||||
'collab.notes.categorySettings': 'Kategorien verwalten',
|
||||
'collab.notes.create': 'Erstellen',
|
||||
'collab.notes.website': 'Website',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.attachFiles': 'Dateien anhängen',
|
||||
'collab.notes.noCategoriesYet': 'Noch keine Kategorien',
|
||||
'collab.notes.emptyDesc': 'Erstelle eine Notiz um loszulegen',
|
||||
'collab.polls.title': 'Umfragen',
|
||||
'collab.polls.new': 'Neue Umfrage',
|
||||
'collab.polls.empty': 'Noch keine Umfragen',
|
||||
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
|
||||
'collab.polls.question': 'Frage',
|
||||
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
|
||||
'collab.polls.addOption': '+ Option hinzufügen',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Umfrage erstellen',
|
||||
'collab.polls.close': 'Schließen',
|
||||
'collab.polls.closed': 'Geschlossen',
|
||||
'collab.polls.votes': '{n} Stimmen',
|
||||
'collab.polls.vote': '{n} Stimme',
|
||||
'collab.polls.multipleChoice': 'Mehrfachauswahl',
|
||||
'collab.polls.multiChoice': 'Mehrfachauswahl',
|
||||
'collab.polls.deadline': 'Frist',
|
||||
'collab.polls.option': 'Option',
|
||||
'collab.polls.options': 'Optionen',
|
||||
'collab.polls.delete': 'Löschen',
|
||||
'collab.polls.closedSection': 'Geschlossen',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -127,6 +127,9 @@ const en = {
|
||||
'settings.language': 'Language',
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.on': 'On',
|
||||
'settings.off': 'Off',
|
||||
'settings.account': 'Account',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
@@ -281,6 +284,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',
|
||||
@@ -498,7 +507,7 @@ const en = {
|
||||
'dayplan.pdfError': 'Failed to export PDF',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place',
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
@@ -523,6 +532,8 @@ const en = {
|
||||
'places.formTime': 'Time',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'End',
|
||||
'places.endTimeBeforeStart': 'End time is before start time',
|
||||
'places.timeCollision': 'Time overlap with:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Personal notes...',
|
||||
'places.formReservation': 'Reservation',
|
||||
@@ -549,6 +560,7 @@ const en = {
|
||||
'inspector.website': 'Open Website',
|
||||
'inspector.addRes': 'Reservation',
|
||||
'inspector.editRes': 'Edit Reservation',
|
||||
'inspector.participants': 'Participants',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Bookings',
|
||||
@@ -621,7 +633,7 @@ const en = {
|
||||
'budget.table.days': 'Days',
|
||||
'budget.table.perPerson': 'Per Person',
|
||||
'budget.table.perDay': 'Per Day',
|
||||
'budget.table.perPersonDay': 'Per Person/Day',
|
||||
'budget.table.perPersonDay': 'P. p / Day',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.newEntry': 'New Entry',
|
||||
'budget.defaultEntry': 'New Entry',
|
||||
@@ -632,6 +644,10 @@ const en = {
|
||||
'budget.editTooltip': 'Click to edit',
|
||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
||||
'budget.deleteCategory': 'Delete Category',
|
||||
'budget.perPerson': 'Per Person',
|
||||
'budget.paid': 'Paid',
|
||||
'budget.open': 'Open',
|
||||
'budget.noMembers': 'No members assigned',
|
||||
|
||||
// Files
|
||||
'files.title': 'Files',
|
||||
@@ -641,11 +657,14 @@ 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',
|
||||
'files.filterImages': 'Images',
|
||||
'files.filterDocs': 'Documents',
|
||||
'files.filterCollab': 'Collab Notes',
|
||||
'files.sourceCollab': 'From Collab Notes',
|
||||
'files.empty': 'No files yet',
|
||||
'files.emptyHint': 'Upload files to attach them to your trip',
|
||||
'files.openTab': 'Open in new tab',
|
||||
@@ -856,8 +875,8 @@ const en = {
|
||||
'planner.placeN': '{n} places',
|
||||
'planner.addNote': 'Add note',
|
||||
'planner.noEntries': 'No entries for this day',
|
||||
'planner.addPlace': 'Add place',
|
||||
'planner.addPlaceShort': '+ Add place',
|
||||
'planner.addPlace': 'Add place/activity',
|
||||
'planner.addPlaceShort': '+ Add place/activity',
|
||||
'planner.resPending': 'Reservation pending · ',
|
||||
'planner.resConfirmed': 'Reservation confirmed · ',
|
||||
'planner.notePlaceholder': 'Note\u2026',
|
||||
@@ -911,6 +930,7 @@ const en = {
|
||||
'day.hourlyForecast': 'Hourly Forecast',
|
||||
'day.climateHint': 'Historical averages — real forecast available within 16 days of this date.',
|
||||
'day.noWeather': 'No weather data available. Add a place with coordinates.',
|
||||
'day.overview': 'Daily Overview',
|
||||
'day.accommodation': 'Accommodation',
|
||||
'day.addAccommodation': 'Add accommodation',
|
||||
'day.hotelDayRange': 'Apply to days',
|
||||
@@ -921,6 +941,75 @@ const en = {
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Edit accommodation',
|
||||
'day.reservations': 'Reservations',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notes',
|
||||
'collab.tabs.polls': 'Polls',
|
||||
'collab.whatsNext.title': "What's Next",
|
||||
'collab.whatsNext.today': 'Today',
|
||||
'collab.whatsNext.tomorrow': 'Tomorrow',
|
||||
'collab.whatsNext.empty': 'No upcoming activities',
|
||||
'collab.whatsNext.until': 'to',
|
||||
'collab.whatsNext.emptyHint': 'Activities with times will appear here',
|
||||
'collab.chat.send': 'Send',
|
||||
'collab.chat.placeholder': 'Type a message...',
|
||||
'collab.chat.empty': 'Start the conversation',
|
||||
'collab.chat.emptyHint': 'Messages are shared with all trip members',
|
||||
'collab.chat.today': 'Today',
|
||||
'collab.chat.yesterday': 'Yesterday',
|
||||
'collab.chat.deletedMessage': 'deleted a message',
|
||||
'collab.chat.loadMore': 'Load older messages',
|
||||
'collab.chat.justNow': 'just now',
|
||||
'collab.chat.minutesAgo': '{n}m ago',
|
||||
'collab.chat.hoursAgo': '{n}h ago',
|
||||
'collab.chat.yesterday': 'yesterday',
|
||||
'collab.notes.title': 'Notes',
|
||||
'collab.notes.new': 'New Note',
|
||||
'collab.notes.empty': 'No notes yet',
|
||||
'collab.notes.emptyHint': 'Start capturing ideas and plans',
|
||||
'collab.notes.all': 'All',
|
||||
'collab.notes.titlePlaceholder': 'Note title',
|
||||
'collab.notes.contentPlaceholder': 'Write something...',
|
||||
'collab.notes.categoryPlaceholder': 'Category',
|
||||
'collab.notes.newCategory': 'New category...',
|
||||
'collab.notes.category': 'Category',
|
||||
'collab.notes.noCategory': 'No category',
|
||||
'collab.notes.color': 'Color',
|
||||
'collab.notes.save': 'Save',
|
||||
'collab.notes.cancel': 'Cancel',
|
||||
'collab.notes.edit': 'Edit',
|
||||
'collab.notes.delete': 'Delete',
|
||||
'collab.notes.pin': 'Pin',
|
||||
'collab.notes.unpin': 'Unpin',
|
||||
'collab.notes.daysAgo': '{n}d ago',
|
||||
'collab.notes.categorySettings': 'Manage Categories',
|
||||
'collab.notes.create': 'Create',
|
||||
'collab.notes.website': 'Website',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.attachFiles': 'Attach files',
|
||||
'collab.notes.noCategoriesYet': 'No categories yet',
|
||||
'collab.notes.emptyDesc': 'Create a note to get started',
|
||||
'collab.polls.title': 'Polls',
|
||||
'collab.polls.new': 'New Poll',
|
||||
'collab.polls.empty': 'No polls yet',
|
||||
'collab.polls.emptyHint': 'Ask the group and vote together',
|
||||
'collab.polls.question': 'Question',
|
||||
'collab.polls.questionPlaceholder': 'What should we do?',
|
||||
'collab.polls.addOption': '+ Add option',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Create Poll',
|
||||
'collab.polls.close': 'Close',
|
||||
'collab.polls.closed': 'Closed',
|
||||
'collab.polls.votes': '{n} votes',
|
||||
'collab.polls.vote': '{n} vote',
|
||||
'collab.polls.multipleChoice': 'Multiple choice',
|
||||
'collab.polls.multiChoice': 'Multiple choice',
|
||||
'collab.polls.deadline': 'Deadline',
|
||||
'collab.polls.option': 'Option',
|
||||
'collab.polls.options': 'Options',
|
||||
'collab.polls.delete': 'Delete',
|
||||
'collab.polls.closedSection': 'Closed',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -290,6 +290,11 @@ body {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
@@ -305,6 +310,11 @@ body {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
|
||||
|
||||
/* Einheitliche Formular-Inputs */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed File Types */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.fileTypes')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.fileTypesHint')}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<input
|
||||
type="text"
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-2">{t('admin.fileTypesFormat')}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingFileTypes(true)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes })
|
||||
toast.success(t('admin.fileTypesSaved'))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
finally { setSavingFileTypes(false) }
|
||||
}}
|
||||
disabled={savingFileTypes}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400 mt-3"
|
||||
>
|
||||
{savingFileTypes ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function LoginPage() {
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{language === 'en' ? 'DE' : 'EN'}
|
||||
{language === 'en' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
|
||||
{/* Left — branding */}
|
||||
|
||||
@@ -330,6 +330,35 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Account */}
|
||||
|
||||
@@ -16,12 +16,14 @@ import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
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 } from '../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
||||
import { calculateRoute } from '../components/Map/RouteCalculator'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
@@ -37,6 +39,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(() => {})
|
||||
@@ -46,7 +50,10 @@ export default function TripPlannerPage() {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
@@ -56,12 +63,17 @@ export default function TripPlannerPage() {
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
|
||||
return saved || 'plan'
|
||||
})
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
setActiveTab(tabId)
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
||||
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
|
||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
|
||||
}
|
||||
@@ -89,12 +101,14 @@ export default function TripPlannerPage() {
|
||||
}, [])
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -104,6 +118,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])
|
||||
|
||||
@@ -117,9 +136,21 @@ export default function TripPlannerPage() {
|
||||
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])
|
||||
|
||||
@@ -154,17 +185,34 @@ export default function TripPlannerPage() {
|
||||
return places.filter(p => p.lat && p.lng)
|
||||
}, [places])
|
||||
|
||||
const updateRouteForDay = useCallback((dayId) => {
|
||||
if (!dayId) { setRoute(null); setRouteInfo(null); return }
|
||||
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId) => {
|
||||
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(waypoints.map(p => [p.lat, p.lng]))
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
// Calculate per-segment travel times
|
||||
const segments = []
|
||||
for (let i = 0; i < waypoints.length - 1; 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]
|
||||
try {
|
||||
const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking')
|
||||
segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText })
|
||||
} catch {
|
||||
segments.push({ mid, from, to, walkingText: '?', drivingText: '?' })
|
||||
}
|
||||
}
|
||||
setRouteSegments(segments)
|
||||
} else {
|
||||
setRoute(null)
|
||||
setRouteSegments([])
|
||||
}
|
||||
setRouteInfo(null)
|
||||
}, [tripStore])
|
||||
}, [tripStore, routeCalcEnabled])
|
||||
|
||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||
const changed = dayId !== selectedDayId
|
||||
@@ -198,7 +246,14 @@ export default function TripPlannerPage() {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
if (editingPlace) {
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||
// Always strip time fields from place update — time is per-assignment only
|
||||
const { place_time, end_time, ...placeData } = data
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
|
||||
// If editing from assignment context, save time per-assignment
|
||||
if (editingAssignmentId) {
|
||||
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
||||
await tripStore.refreshDays(tripId)
|
||||
}
|
||||
// Upload pending files with place_id
|
||||
if (pendingFiles?.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -221,7 +276,7 @@ export default function TripPlannerPage() {
|
||||
}
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
}
|
||||
}, [editingPlace, tripId, tripStore, toast])
|
||||
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
|
||||
|
||||
const handleDeletePlace = useCallback(async (placeId) => {
|
||||
if (!confirm(t('trip.confirm.deletePlace'))) return
|
||||
@@ -245,7 +300,6 @@ export default function TripPlannerPage() {
|
||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||
try {
|
||||
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||
updateRouteForDay(dayId)
|
||||
}
|
||||
catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||
@@ -306,6 +360,18 @@ export default function TripPlannerPage() {
|
||||
return map
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
// Auto-update route when assignments change
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) return
|
||||
const da = (assignments[String(selectedDayId)] || []).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(waypoints.map(p => [p.lat, p.lng]))
|
||||
} else {
|
||||
setRoute(null)
|
||||
}
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
// Places assigned to selected day (with coords) — used for map fitting
|
||||
const dayPlaces = useMemo(() => {
|
||||
if (!selectedDayId) return []
|
||||
@@ -380,6 +446,7 @@ export default function TripPlannerPage() {
|
||||
places={mapPlaces()}
|
||||
dayPlaces={dayPlaces}
|
||||
route={route}
|
||||
routeSegments={routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
@@ -393,19 +460,6 @@ export default function TripPlannerPage() {
|
||||
hasInspector={!!selectedPlace}
|
||||
/>
|
||||
|
||||
{routeInfo && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
|
||||
borderRadius: 99, padding: '6px 20px', zIndex: 30,
|
||||
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
|
||||
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
|
||||
}}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: '#d1d5db' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
@@ -448,10 +502,13 @@ export default function TripPlannerPage() {
|
||||
onReorder={handleReorder}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
reservations={reservations}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
@@ -509,6 +566,8 @@ export default function TripPlannerPage() {
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,12 +619,37 @@ export default function TripPlannerPage() {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||
onEdit={() => {
|
||||
// When editing from assignment context, use assignment-level times
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
}}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
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 {}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -580,7 +664,7 @@ export default function TripPlannerPage() {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
|
||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
||||
}
|
||||
</div>
|
||||
@@ -614,8 +698,8 @@ export default function TripPlannerPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} />
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -629,12 +713,19 @@ export default function TripPlannerPage() {
|
||||
places={places}
|
||||
reservations={reservations}
|
||||
tripId={tripId}
|
||||
allowedFileTypes={allowedFileTypes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collab' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||
|
||||
@@ -201,6 +201,20 @@ export const useTripStore = create((set, get) => ({
|
||||
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':
|
||||
@@ -275,6 +289,21 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
@@ -302,7 +331,7 @@ export const useTripStore = create((set, get) => ({
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a)
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
@@ -668,6 +697,27 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ services:
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.5.7",
|
||||
"version": "2.6.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
|
||||
+180
-14
@@ -316,6 +316,54 @@ function initDb() {
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Collab addon tables
|
||||
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category TEXT DEFAULT 'General',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
pinned INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multiple INTEGER DEFAULT 0,
|
||||
closed INTEGER DEFAULT 0,
|
||||
deadline TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
option_index INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(poll_id, user_id, option_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
@@ -337,6 +385,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 +494,118 @@ 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)
|
||||
)
|
||||
`);
|
||||
},
|
||||
// 25: Collab addon tables
|
||||
() => {
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category TEXT DEFAULT 'General',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
pinned INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multiple INTEGER DEFAULT 0,
|
||||
closed INTEGER DEFAULT 0,
|
||||
deadline TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
option_index INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(poll_id, user_id, option_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||
`);
|
||||
// Ensure collab addon exists for existing installations
|
||||
try {
|
||||
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run();
|
||||
} catch {}
|
||||
},
|
||||
// 26: Per-assignment times (instead of shared place times)
|
||||
() => {
|
||||
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {}
|
||||
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {}
|
||||
// Copy existing place times to assignments as initial values
|
||||
try {
|
||||
_db.exec(`
|
||||
UPDATE day_assignments SET
|
||||
assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id),
|
||||
assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id)
|
||||
`);
|
||||
} catch {}
|
||||
},
|
||||
// 27: Budget item members (per-person expense tracking)
|
||||
() => {
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS budget_item_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
paid INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(budget_item_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id);
|
||||
`);
|
||||
},
|
||||
// 28: Message reactions
|
||||
() => {
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collab_message_reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id);
|
||||
`);
|
||||
},
|
||||
// 29: Soft-delete for chat messages
|
||||
() => {
|
||||
try { _db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {}
|
||||
},
|
||||
// 30: Note attachments + website field
|
||||
() => {
|
||||
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
|
||||
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
|
||||
},
|
||||
// Future migrations go here (append only, never reorder)
|
||||
];
|
||||
|
||||
@@ -476,21 +644,19 @@ function initDb() {
|
||||
console.error('Error seeding categories:', err.message);
|
||||
}
|
||||
|
||||
// Seed: default addons
|
||||
// Seed: default addons (INSERT OR IGNORE so migration-inserted rows don't block seeding)
|
||||
try {
|
||||
const existingAddons = _db.prepare('SELECT COUNT(*) as count FROM addons').get();
|
||||
if (existingAddons.count === 0) {
|
||||
const defaultAddons = [
|
||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', sort_order: 0 },
|
||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', sort_order: 1 },
|
||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
|
||||
];
|
||||
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
|
||||
console.log('Default addons seeded');
|
||||
}
|
||||
const defaultAddons = [
|
||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
];
|
||||
const insertAddon = _db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
console.log('Default addons seeded');
|
||||
} catch (err) {
|
||||
console.error('Error seeding addons:', err.message);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
|
||||
const weatherRoutes = require('./routes/weather');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const budgetRoutes = require('./routes/budget');
|
||||
const collabRoutes = require('./routes/collab');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
|
||||
const oidcRoutes = require('./routes/oidc');
|
||||
@@ -83,8 +84,10 @@ app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
|
||||
@@ -13,7 +13,9 @@ function getAssignmentWithPlace(assignmentId) {
|
||||
const a = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -30,11 +32,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,
|
||||
@@ -79,7 +89,9 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -105,12 +117,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 +266,66 @@ 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/time — update per-assignment time
|
||||
router.put('/trips/:tripId/assignments/:id/time', 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 assignment = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da
|
||||
JOIN days d ON da.day_id = d.id
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`).get(id, tripId);
|
||||
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||
|
||||
const { place_time, end_time } = req.body;
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||
.run(place_time ?? null, end_time ?? null, id);
|
||||
|
||||
const updated = getAssignmentWithPlace(id);
|
||||
res.json({ assignment: updated });
|
||||
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ function verifyTripOwnership(tripId, userId) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
function loadItemMembers(itemId) {
|
||||
return db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ?
|
||||
`).all(itemId);
|
||||
}
|
||||
|
||||
function avatarUrl(user) {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
// GET /api/trips/:tripId/budget
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
@@ -20,9 +33,48 @@ router.get('/', authenticate, (req, res) => {
|
||||
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
||||
).all(tripId);
|
||||
|
||||
// Batch-load all members
|
||||
const itemIds = items.map(i => i.id);
|
||||
const membersByItem = {};
|
||||
if (itemIds.length > 0) {
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
`).all(...itemIds);
|
||||
for (const m of allMembers) {
|
||||
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
|
||||
membersByItem[m.budget_item_id].push({
|
||||
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m)
|
||||
});
|
||||
}
|
||||
}
|
||||
items.forEach(item => { item.members = membersByItem[item.id] || []; });
|
||||
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
// GET /api/trips/:tripId/budget/summary/per-person (must be before /:id routes)
|
||||
router.get('/summary/per-person', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const summary = db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar,
|
||||
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
FROM budget_item_members bm
|
||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bi.trip_id = ?
|
||||
GROUP BY bm.user_id
|
||||
`).all(tripId);
|
||||
|
||||
res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) });
|
||||
});
|
||||
|
||||
// POST /api/trips/:tripId/budget
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
@@ -50,6 +102,7 @@ router.post('/', authenticate, (req, res) => {
|
||||
);
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
item.members = [];
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
|
||||
});
|
||||
@@ -87,10 +140,63 @@ router.put('/:id', authenticate, (req, res) => {
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
|
||||
updated.members = loadItemMembers(id);
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/budget/:id/members
|
||||
router.put('/:id/members', 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 item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return res.status(404).json({ error: 'Budget item not found' });
|
||||
|
||||
const { user_ids } = req.body;
|
||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||
|
||||
// Preserve paid status for existing members
|
||||
const existingPaid = {};
|
||||
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id);
|
||||
for (const e of existing) existingPaid[e.user_id] = e.paid;
|
||||
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
if (user_ids.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
|
||||
for (const userId of user_ids) insert.run(id, userId, existingPaid[userId] || 0);
|
||||
// Auto-update persons count
|
||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(user_ids.length, id);
|
||||
} else {
|
||||
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
|
||||
res.json({ members, item: updated });
|
||||
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members, persons: updated.persons }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/budget/:id/members/:userId/paid
|
||||
router.put('/:id/members/:userId/paid', authenticate, (req, res) => {
|
||||
const { tripId, id, userId } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { paid } = req.body;
|
||||
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
|
||||
.run(paid ? 1 : 0, id, userId);
|
||||
|
||||
const member = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ? AND bm.user_id = ?
|
||||
`).get(id, userId);
|
||||
|
||||
const result = member ? { ...member, avatar_url: avatarUrl(member) } : null;
|
||||
res.json({ member: result });
|
||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/budget/:id
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const filesDir = path.join(__dirname, '../../uploads/files');
|
||||
const noteUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
|
||||
filename: (req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
|
||||
}),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
function verifyTripAccess(tripId, userId) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
function avatarUrl(user) {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
function formatNote(note) {
|
||||
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id);
|
||||
return {
|
||||
...note,
|
||||
avatar_url: avatarUrl(note),
|
||||
attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })),
|
||||
};
|
||||
}
|
||||
|
||||
function loadReactions(messageId) {
|
||||
return db.prepare(`
|
||||
SELECT r.emoji, r.user_id, u.username
|
||||
FROM collab_message_reactions r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.message_id = ?
|
||||
`).all(messageId);
|
||||
}
|
||||
|
||||
function groupReactions(reactions) {
|
||||
const map = {};
|
||||
for (const r of reactions) {
|
||||
if (!map[r.emoji]) map[r.emoji] = [];
|
||||
map[r.emoji].push({ user_id: r.user_id, username: r.username });
|
||||
}
|
||||
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
|
||||
}
|
||||
|
||||
function formatMessage(msg, reactions) {
|
||||
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
|
||||
}
|
||||
|
||||
// ─── NOTES ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /notes
|
||||
router.get('/notes', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const notes = db.prepare(`
|
||||
SELECT n.*, u.username, u.avatar
|
||||
FROM collab_notes n
|
||||
JOIN users u ON n.user_id = u.id
|
||||
WHERE n.trip_id = ?
|
||||
ORDER BY n.pinned DESC, n.updated_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
res.json({ notes: notes.map(formatNote) });
|
||||
});
|
||||
|
||||
// POST /notes
|
||||
router.post('/notes', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { title, content, category, color, website } = req.body;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1', website || null);
|
||||
|
||||
const note = db.prepare(`
|
||||
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const formatted = formatNote(note);
|
||||
res.status(201).json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /notes/:id
|
||||
router.put('/notes/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { title, content, category, color, pinned, website } = req.body;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
db.prepare(`
|
||||
UPDATE collab_notes SET
|
||||
title = COALESCE(?, title),
|
||||
content = CASE WHEN ? THEN ? ELSE content END,
|
||||
category = COALESCE(?, category),
|
||||
color = COALESCE(?, color),
|
||||
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
|
||||
website = CASE WHEN ? THEN ? ELSE website END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
content !== undefined ? 1 : 0, content !== undefined ? content : null,
|
||||
category || null,
|
||||
color || null,
|
||||
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
|
||||
website !== undefined ? 1 : 0, website !== undefined ? website : null,
|
||||
id
|
||||
);
|
||||
|
||||
const note = db.prepare(`
|
||||
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
|
||||
`).get(id);
|
||||
|
||||
const formatted = formatNote(note);
|
||||
res.json({ note: formatted });
|
||||
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /notes/:id
|
||||
router.delete('/notes/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
// Delete attached files (physical + DB)
|
||||
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(id);
|
||||
for (const f of noteFiles) {
|
||||
const filePath = path.join(__dirname, '../../uploads', f.filename);
|
||||
try { fs.unlinkSync(filePath) } catch {}
|
||||
}
|
||||
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(id);
|
||||
|
||||
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// POST /notes/:id/files — upload attachment to note
|
||||
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, id, `files/${req.file.filename}`, req.file.originalname, req.file.size, req.file.mimetype);
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ file: { ...file, url: `/uploads/${file.filename}` } });
|
||||
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /notes/:id/files/:fileId — remove attachment
|
||||
router.delete('/notes/:id/files/:fileId', authenticate, (req, res) => {
|
||||
const { tripId, id, fileId } = req.params;
|
||||
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id);
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
// Delete physical file
|
||||
const filePath = path.join(__dirname, '../../uploads', file.filename);
|
||||
try { fs.unlinkSync(filePath) } catch {}
|
||||
|
||||
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
|
||||
res.json({ success: true });
|
||||
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// ─── POLLS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPollWithVotes(pollId) {
|
||||
const poll = db.prepare(`
|
||||
SELECT p.*, u.username, u.avatar
|
||||
FROM collab_polls p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ?
|
||||
`).get(pollId);
|
||||
|
||||
if (!poll) return null;
|
||||
|
||||
const options = JSON.parse(poll.options);
|
||||
|
||||
const votes = db.prepare(`
|
||||
SELECT v.option_index, v.user_id, u.username, u.avatar
|
||||
FROM collab_poll_votes v
|
||||
JOIN users u ON v.user_id = u.id
|
||||
WHERE v.poll_id = ?
|
||||
`).all(pollId);
|
||||
|
||||
// Transform: nest voters into each option (frontend expects options[i].voters)
|
||||
const formattedOptions = options.map((label, idx) => ({
|
||||
label: typeof label === 'string' ? label : label.label || label,
|
||||
voters: votes
|
||||
.filter(v => v.option_index === idx)
|
||||
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
||||
}));
|
||||
|
||||
return {
|
||||
...poll,
|
||||
avatar_url: avatarUrl(poll),
|
||||
options: formattedOptions,
|
||||
is_closed: !!poll.closed,
|
||||
multiple_choice: !!poll.multiple,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /polls
|
||||
router.get('/polls', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
const polls = rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
|
||||
res.json({ polls });
|
||||
});
|
||||
|
||||
// POST /polls
|
||||
router.post('/polls', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { question, options, multiple, multiple_choice, deadline } = req.body;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!question) return res.status(400).json({ error: 'Question is required' });
|
||||
if (!Array.isArray(options) || options.length < 2) {
|
||||
return res.status(400).json({ error: 'At least 2 options are required' });
|
||||
}
|
||||
|
||||
// Accept both 'multiple' and 'multiple_choice' from frontend
|
||||
const isMultiple = multiple || multiple_choice;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, req.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null);
|
||||
|
||||
const poll = getPollWithVotes(result.lastInsertRowid);
|
||||
res.status(201).json({ poll });
|
||||
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// POST /polls/:id/vote
|
||||
router.post('/polls/:id/vote', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { option_index } = req.body;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
|
||||
|
||||
const options = JSON.parse(poll.options);
|
||||
if (option_index < 0 || option_index >= options.length) {
|
||||
return res.status(400).json({ error: 'Invalid option index' });
|
||||
}
|
||||
|
||||
// Toggle: if vote exists, remove it; otherwise add it
|
||||
const existingVote = db.prepare(
|
||||
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
|
||||
).get(id, req.user.id, option_index);
|
||||
|
||||
if (existingVote) {
|
||||
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
|
||||
} else {
|
||||
if (!poll.multiple) {
|
||||
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
|
||||
}
|
||||
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(id, req.user.id, option_index);
|
||||
}
|
||||
|
||||
const updatedPoll = getPollWithVotes(id);
|
||||
res.json({ poll: updatedPoll });
|
||||
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /polls/:id/close
|
||||
router.put('/polls/:id/close', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
|
||||
|
||||
const updatedPoll = getPollWithVotes(id);
|
||||
res.json({ poll: updatedPoll });
|
||||
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /polls/:id
|
||||
router.delete('/polls/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /messages
|
||||
router.get('/messages', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { before } = req.query;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const query = `
|
||||
SELECT m.*, u.username, u.avatar,
|
||||
rm.text AS reply_text, ru.username AS reply_username
|
||||
FROM collab_messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const messages = before
|
||||
? db.prepare(query).all(tripId, before)
|
||||
: db.prepare(query).all(tripId);
|
||||
|
||||
messages.reverse();
|
||||
// Batch-load reactions
|
||||
const msgIds = messages.map(m => m.id);
|
||||
const reactionsByMsg = {};
|
||||
if (msgIds.length > 0) {
|
||||
const allReactions = db.prepare(`
|
||||
SELECT r.message_id, r.emoji, r.user_id, u.username
|
||||
FROM collab_message_reactions r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
|
||||
`).all(...msgIds);
|
||||
for (const r of allReactions) {
|
||||
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
|
||||
reactionsByMsg[r.message_id].push(r);
|
||||
}
|
||||
}
|
||||
res.json({ messages: messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))) });
|
||||
});
|
||||
|
||||
// POST /messages
|
||||
router.post('/messages', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { text, reply_to } = req.body;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
|
||||
|
||||
if (reply_to) {
|
||||
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
|
||||
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
|
||||
`).run(tripId, req.user.id, text.trim(), reply_to || null);
|
||||
|
||||
const message = db.prepare(`
|
||||
SELECT m.*, u.username, u.avatar,
|
||||
rm.text AS reply_text, ru.username AS reply_username
|
||||
FROM collab_messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const formatted = formatMessage(message);
|
||||
res.status(201).json({ message: formatted });
|
||||
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// POST /messages/:id/react — toggle emoji reaction
|
||||
router.post('/messages/:id/react', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { emoji } = req.body;
|
||||
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
|
||||
|
||||
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!msg) return res.status(404).json({ error: 'Message not found' });
|
||||
|
||||
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(id, req.user.id, emoji);
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
|
||||
} else {
|
||||
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(id, req.user.id, emoji);
|
||||
}
|
||||
|
||||
const reactions = groupReactions(loadReactions(id));
|
||||
res.json({ reactions });
|
||||
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /messages/:id (soft-delete)
|
||||
router.delete('/messages/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
if (Number(message.user_id) !== Number(req.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' });
|
||||
|
||||
db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: message.username || req.user.username }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// ─── LINK PREVIEW ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/link-preview', authenticate, (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const fetch = require('node-fetch');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
||||
})
|
||||
.then(r => {
|
||||
clearTimeout(timeout);
|
||||
if (!r.ok) throw new Error('Fetch failed');
|
||||
return r.text();
|
||||
})
|
||||
.then(html => {
|
||||
const get = (prop) => {
|
||||
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|
||||
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|
||||
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
|
||||
|
||||
res.json({
|
||||
title: get('title') || (titleTag ? titleTag[1].trim() : null),
|
||||
description: get('description') || (descMeta ? descMeta[1].trim() : null),
|
||||
image: get('image') || null,
|
||||
site_name: get('site_name') || null,
|
||||
url,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timeout);
|
||||
res.json({ title: null, description: null, image: null, url });
|
||||
});
|
||||
} catch {
|
||||
res.json({ title: null, description: null, image: null, url });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -13,7 +13,9 @@ function getAssignmentsForDay(dayId) {
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -89,7 +91,9 @@ router.get('/', authenticate, (req, res) => {
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -117,6 +121,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 +140,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,
|
||||
|
||||
+15
-13
@@ -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'));
|
||||
@@ -55,7 +57,7 @@ function verifyTripOwnership(tripId, userId) {
|
||||
function formatFile(file) {
|
||||
return {
|
||||
...file,
|
||||
url: `/uploads/files/${file.filename}`,
|
||||
url: file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -298,17 +298,16 @@ router.get('/detailed', authenticate, async (req, res) => {
|
||||
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
// Beyond 16-day forecast window → archive API (daily only, no hourly)
|
||||
// Beyond 16-day forecast window → archive API with hourly data from same date last year
|
||||
if (diffDays > 16) {
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
const startDate = new Date(refYear, month - 1, day - 2);
|
||||
const endDate = new Date(refYear, month - 1, day + 2);
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
const endStr = endDate.toISOString().slice(0, 10);
|
||||
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
|
||||
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
|
||||
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
||||
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
|
||||
+ `&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -317,36 +316,51 @@ router.get('/detailed', authenticate, async (req, res) => {
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
const hourly = data.hourly;
|
||||
if (!daily || !daily.time || daily.time.length === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
|
||||
for (let i = 0; i < daily.time.length; i++) {
|
||||
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
|
||||
sumMax += daily.temperature_2m_max[i];
|
||||
sumMin += daily.temperature_2m_min[i];
|
||||
sumPrecip += daily.precipitation_sum[i] || 0;
|
||||
count++;
|
||||
const idx = 0;
|
||||
const code = daily.weathercode?.[idx];
|
||||
const avgMax = daily.temperature_2m_max[idx];
|
||||
const avgMin = daily.temperature_2m_min[idx];
|
||||
|
||||
// Build hourly array
|
||||
const hourlyData = [];
|
||||
if (hourly?.time) {
|
||||
for (let i = 0; i < hourly.time.length; i++) {
|
||||
const hour = new Date(hourly.time[i]).getHours();
|
||||
const hCode = hourly.weathercode?.[i];
|
||||
hourlyData.push({
|
||||
hour,
|
||||
temp: Math.round(hourly.temperature_2m[i]),
|
||||
precipitation: hourly.precipitation?.[i] || 0,
|
||||
precipitation_probability: 0, // archive has no probability
|
||||
main: WMO_MAP[hCode] || 'Clouds',
|
||||
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
|
||||
humidity: hourly.relativehumidity_2m?.[i] || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
const avgMax = sumMax / count;
|
||||
const avgMin = sumMin / count;
|
||||
const avgTemp = (avgMax + avgMin) / 2;
|
||||
const avgPrecip = sumPrecip / count;
|
||||
// Format sunrise/sunset
|
||||
let sunrise = null, sunset = null;
|
||||
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
|
||||
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
|
||||
|
||||
const result = {
|
||||
type: 'climate',
|
||||
temp: Math.round(avgTemp),
|
||||
temp: Math.round((avgMax + avgMin) / 2),
|
||||
temp_max: Math.round(avgMax),
|
||||
temp_min: Math.round(avgMin),
|
||||
main: estimateCondition(avgTemp, avgPrecip),
|
||||
precipitation_sum: Math.round(avgPrecip * 10) / 10,
|
||||
main: WMO_MAP[code] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
|
||||
description: descriptions[code] || '',
|
||||
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
|
||||
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
|
||||
sunrise,
|
||||
sunset,
|
||||
hourly: hourlyData,
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CLIMATE_MS);
|
||||
|
||||
Reference in New Issue
Block a user