mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df9a20e55 | |||
| e56930ddaf | |||
| 3ecf7e5bef | |||
| c100cab90f | |||
| 99e428a6c5 | |||
| 3146e0f8b3 | |||
| 2a490cf532 | |||
| ef9e22b34d | |||
| ad64df42ed | |||
| 4af35b162e | |||
| 20c1858b23 | |||
| e986c9ab27 | |||
| 61ffdb553e | |||
| 1abc9b2bc7 | |||
| 8713443665 | |||
| c92c02e1b8 | |||
| 993d9bf713 | |||
| c7e4b2781b | |||
| a88cd772cf | |||
| 98d11d4267 | |||
| 6707dac4a9 | |||
| c552472b63 | |||
| 5fd66f4833 | |||
| 50609b078a | |||
| 42b45dcd82 | |||
| 9dd9057b7b | |||
| 23987c76bb | |||
| 7173e82fe8 | |||
| 72dfa2c60c | |||
| d19305bda4 | |||
| 7aa2f6e4f2 | |||
| 3e64cb86a6 | |||
| e4efcf0840 | |||
| e34f40b686 | |||
| 3701ab6cad | |||
| e91f592f22 |
+3
-1
@@ -65,4 +65,6 @@ coverage
|
||||
test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
.full-review
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
@@ -40,6 +40,8 @@ See `values.yaml` for more options.
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
|
||||
@@ -21,3 +21,9 @@
|
||||
|
||||
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||
|
||||
5. Persistence:
|
||||
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
|
||||
The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
|
||||
when the pod restarts. Use only for testing.
|
||||
|
||||
@@ -82,8 +82,16 @@ spec:
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: uploads
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -18,7 +18,9 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
|
||||
@@ -101,15 +101,20 @@ existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||
storageClassName: ""
|
||||
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
uploads:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
# Specify an existing PVC to bind. The other values are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
resources:
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import HelpPage from './pages/HelpPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
@@ -221,6 +222,22 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
@@ -339,6 +341,10 @@ export const tripsApi = {
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
|
||||
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
|
||||
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
|
||||
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
@@ -379,6 +385,8 @@ export const placesApi = {
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -400,6 +408,10 @@ export const packingApi = {
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
|
||||
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
|
||||
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
|
||||
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
@@ -579,9 +591,16 @@ export const journeyApi = {
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
@@ -703,6 +722,17 @@ export const configApi = {
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export interface HelpNavItem { title: string; slug: string }
|
||||
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
|
||||
export interface HelpPageData { slug: string; title: string; markdown: string }
|
||||
|
||||
export const helpApi = {
|
||||
index: (): Promise<{ sections: HelpNavSection[] }> =>
|
||||
apiClient.get('/help/index').then(r => r.data),
|
||||
page: (slug: string): Promise<HelpPageData> =>
|
||||
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
||||
it('supports custom split amounts on save', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
@@ -108,18 +108,22 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||
expect(nums()[2].value).toBe('50')
|
||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||
await user.type(nums()[0], '100') // total = 100
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Custom/i }))
|
||||
|
||||
const customInputs = screen.getAllByPlaceholderText('50.00')
|
||||
await user.type(customInputs[0], '30')
|
||||
await user.type(customInputs[1], '70')
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
expect(posted!.payers).toEqual([
|
||||
expect.objectContaining({ amount: 100 })
|
||||
])
|
||||
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||
]))
|
||||
@@ -194,4 +198,60 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(posted!.member_ids).toEqual([])
|
||||
expect(posted!.payers).toEqual([])
|
||||
})
|
||||
|
||||
it('supports itemized receipt ticket manual entry and split assignment', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 10 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||
|
||||
const addBtn = screen.getByRole('button', { name: /Add item/i })
|
||||
await user.click(addBtn)
|
||||
await user.click(addBtn)
|
||||
await user.click(addBtn)
|
||||
|
||||
const itemNames = screen.getAllByPlaceholderText('Item name')
|
||||
const itemPrices = screen.getAllByPlaceholderText('0.00')
|
||||
|
||||
await user.type(itemNames[0], 'Apples')
|
||||
await user.type(itemPrices[1], '10')
|
||||
|
||||
await user.type(itemNames[1], 'chocolate cake')
|
||||
await user.type(itemPrices[2], '50')
|
||||
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
|
||||
await user.click(bobButtons[1])
|
||||
|
||||
await user.type(itemNames[2], 'Milk')
|
||||
await user.type(itemPrices[3], '40')
|
||||
|
||||
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
|
||||
|
||||
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 75 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 25 }),
|
||||
]))
|
||||
expect(posted!.note).toContain('TICKETJSON:')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,69 @@ import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length
|
||||
if (n === 0) return {}
|
||||
|
||||
const totalCents = Math.round(total * 100)
|
||||
const baseCents = Math.floor(totalCents / n)
|
||||
const remainder = totalCents % n
|
||||
|
||||
const shares: Record<number, number> = {}
|
||||
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
|
||||
const startIndex = itemId % n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const member = sortedMembers[i]
|
||||
const hasExtraCent = ((i - startIndex + n) % n) < remainder
|
||||
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
|
||||
}
|
||||
|
||||
return shares
|
||||
}
|
||||
|
||||
export interface TicketItem {
|
||||
id: string
|
||||
name: string
|
||||
price: string
|
||||
participants: Set<number>
|
||||
}
|
||||
|
||||
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
|
||||
const shares: Record<number, number> = {}
|
||||
let totalCents = 0
|
||||
|
||||
for (const item of items) {
|
||||
const priceNum = parseFloat(item.price) || 0
|
||||
const priceCents = Math.round(priceNum * 100)
|
||||
totalCents += priceCents
|
||||
|
||||
const partIds = [...item.participants]
|
||||
const n = partIds.length
|
||||
if (n === 0) continue
|
||||
|
||||
const baseCents = Math.floor(priceCents / n)
|
||||
const remainder = priceCents % n
|
||||
|
||||
const sortedPartIds = [...partIds].sort((a, b) => a - b)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = sortedPartIds[i]
|
||||
const hasExtraCent = i < remainder
|
||||
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
|
||||
shares[id] = (shares[id] || 0) + shareCents
|
||||
}
|
||||
}
|
||||
|
||||
const finalShares: Record<number, number> = {}
|
||||
for (const id of Object.keys(shares)) {
|
||||
finalShares[Number(id)] = shares[Number(id)] / 100
|
||||
}
|
||||
|
||||
return { shares: finalShares, total: totalCents / 100 }
|
||||
}
|
||||
|
||||
interface CostsPanelProps {
|
||||
tripId: number
|
||||
@@ -105,9 +168,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
const n = (e.members || []).length
|
||||
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
||||
return baseTotal(e) / n
|
||||
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||
if (!myMember) return 0
|
||||
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||
return convert(myMember.amount, curOf(e))
|
||||
}
|
||||
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
|
||||
const totals = useMemo(() => {
|
||||
@@ -790,11 +858,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
// One participant list: a person is "in" the split and may have paid an amount.
|
||||
// Entering the total auto-distributes it equally across the non-pinned participants;
|
||||
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
||||
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
||||
// toward the trip total only, never settlements, until who-paid is filled in).
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
@@ -802,89 +865,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
||||
|
||||
// Payer state: 0 represents "Nobody (planning entry)"
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
|
||||
return existingPayer ? existingPayer.user_id : me
|
||||
})
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
return 'ticket'
|
||||
}
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
|
||||
return hasCustom ? 'custom' : 'equally'
|
||||
}
|
||||
return 'equally'
|
||||
})
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(editing.note.slice(11))
|
||||
return (parsed.items || []).map((item: any) => ({
|
||||
id: String(Math.random()),
|
||||
name: item.name,
|
||||
price: String(item.price),
|
||||
participants: new Set(item.parts || [])
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
m[member.user_id] = String(member.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||
// payer amounts load as pinned so opening an expense never reshuffles them.
|
||||
const [dirty, setDirty] = useState<Set<number>>(() =>
|
||||
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const totalNum = parseFloat(total) || 0
|
||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
||||
const paidEntered = paidSum > 0
|
||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
// No participants = a recorded total with nobody to split with (e.g. a booking
|
||||
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
||||
// people only adds the who-owes-whom split on top.
|
||||
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
|
||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||
const splitCents = (amount: number, n: number): number[] => {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const base = Math.floor(cents / n), rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
// Recompute the non-pinned participants so every paid amount sums to the total.
|
||||
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
||||
const ids = [...parts]
|
||||
const free = ids.filter(id => !dirtySet.has(id))
|
||||
if (free.length === 0) return paidMap
|
||||
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
||||
const shares = splitCents(totalVal - pinnedSum, free.length)
|
||||
const next = { ...paidMap }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
||||
return next
|
||||
}
|
||||
const ticketInfo = useMemo(() => {
|
||||
return calculateTicketShares(ticketItems)
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, editing])
|
||||
|
||||
const placeholderShares = useMemo(() => {
|
||||
const emptyParts = [...participants].filter(id => !customAmounts[id])
|
||||
if (emptyParts.length === 0) return {}
|
||||
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||
const remaining = Math.max(0, totalNum - enteredSum)
|
||||
|
||||
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const valid = name.trim().length > 0 && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
)
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
setTotal(v)
|
||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||
setTotal(v.replace(',', '.'))
|
||||
}
|
||||
const onPaidChange = (id: number, v: string) => {
|
||||
v = v.replace(',', '.')
|
||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||
setDirty(nextDirty)
|
||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
setTicketItems(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: String(Date.now() + Math.random()),
|
||||
name: '',
|
||||
price: '',
|
||||
participants: new Set(people.map(p => p.id))
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const handleUpdateItemName = (id: string, name: string) => {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
|
||||
}
|
||||
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveItem = (id: string) => {
|
||||
setTicketItems(prev => prev.filter(item => item.id !== id))
|
||||
}
|
||||
|
||||
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||
setTicketItems(prev => prev.map(item => {
|
||||
if (item.id === itemId) {
|
||||
const nextParts = new Set(item.participants)
|
||||
if (nextParts.has(userId)) nextParts.delete(userId)
|
||||
else nextParts.add(userId)
|
||||
return { ...item, participants: nextParts }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
||||
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
||||
else nextParts.add(id)
|
||||
setParticipants(nextParts); setDirty(nextDirty)
|
||||
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
||||
const nextParts = new Set(participants)
|
||||
if (nextParts.has(id)) {
|
||||
nextParts.delete(id)
|
||||
setCustomAmounts(prev => {
|
||||
const copy = { ...prev }
|
||||
delete copy[id]
|
||||
return copy
|
||||
})
|
||||
} else {
|
||||
nextParts.add(id)
|
||||
}
|
||||
setParticipants(nextParts)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = [...participants]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const memberList = [...participants].map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
? (parseFloat(customAmounts[id]) || 0)
|
||||
: splitMode === 'ticket'
|
||||
? (ticketInfo.shares[id] || 0)
|
||||
: null
|
||||
}))
|
||||
const data = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
name: name.trim(),
|
||||
category: cat,
|
||||
currency,
|
||||
payers: payerList, member_ids: [...participants],
|
||||
payers: payerList,
|
||||
members: memberList,
|
||||
member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
// Always record the entered total: the server keeps it as-is for an unfinished
|
||||
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
||||
total_price: totalNum,
|
||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
|
||||
items: ticketItems.map(item => ({
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
parts: [...item.participants]
|
||||
}))
|
||||
}) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
onSaved()
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
@@ -906,10 +1072,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
|
||||
onChange={e => onTotalChange(e.target.value)}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -954,39 +1121,165 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map((p, idx) => {
|
||||
const on = participants.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
</button>
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
]}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
|
||||
<button type="button" onClick={() => setSplitMode('equally')}
|
||||
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitEqually') || 'Equally'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setSplitMode('custom')}
|
||||
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitCustom') || 'Custom'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setSplitMode('ticket')}
|
||||
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.splitTicket') || 'Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{splitMode === 'ticket' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ticketItems.map((item, itemIdx) => (
|
||||
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Item name"
|
||||
value={item.name}
|
||||
onChange={e => handleUpdateItemName(item.id, e.target.value)}
|
||||
className="bg-surface-input border border-edge text-content"
|
||||
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
|
||||
/>
|
||||
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={item.price}
|
||||
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
|
||||
{people.map((p, pIdx) => {
|
||||
const active = item.participants.has(p.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
onClick={() => handleToggleItemParticipant(item.id, p.id)}
|
||||
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
|
||||
>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<Plus size={14} /> Add item
|
||||
</button>
|
||||
|
||||
{ticketItems.length > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
|
||||
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{people.map(p => {
|
||||
const share = ticketInfo.shares[p.id] || 0
|
||||
return (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</span>
|
||||
{paidEntered
|
||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map((p, idx) => {
|
||||
const on = participants.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button type="button" onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
{p.is_guest && <GuestBadge size="xs" />}
|
||||
</button>
|
||||
{splitMode === 'equally' ? (
|
||||
on ? (
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
|
||||
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
|
||||
)
|
||||
) : (
|
||||
on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
|
||||
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
{splitMode === 'equally' ? (
|
||||
<span className="text-content-faint">
|
||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
{customBalanced
|
||||
? 'Split matches total'
|
||||
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
@@ -6,9 +6,30 @@ export function isImage(mimeType?: string | null) {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
export function isVideo(mimeType?: string | null) {
|
||||
return !!mimeType && mimeType.startsWith('video/')
|
||||
}
|
||||
|
||||
/** Image or video — the file types that open in the media lightbox (#823). */
|
||||
export function isMedia(mimeType?: string | null) {
|
||||
return isImage(mimeType) || isVideo(mimeType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown file (#1345). Detected by EXTENSION first — browsers often send an
|
||||
* empty / octet-stream / text/plain MIME for .md — falling back to the markdown
|
||||
* MIME types.
|
||||
*/
|
||||
export function isMarkdown(mimeType?: string | null, name?: string | null) {
|
||||
const ext = (name || '').toLowerCase().split('.').pop()
|
||||
if (ext === 'md' || ext === 'markdown') return true
|
||||
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (isVideo(mimeType)) return FileVideo
|
||||
if (isImage(mimeType)) return FileImage
|
||||
return File
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||
}));
|
||||
|
||||
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
|
||||
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}));
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
||||
vi.mock('remark-breaks', () => ({ default: () => ({}) }));
|
||||
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
|
||||
|
||||
// Mock filesApi
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
@@ -289,6 +298,21 @@ describe('FileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-034: markdown file click opens an inline rendered preview (#1345)', async () => {
|
||||
server.use(http.get('http://localhost/signed-url', () => HttpResponse.text('# Hello heading\n\nworld body')));
|
||||
const files = [buildFile({ id: 1, mime_type: 'text/markdown', original_name: 'notes.md' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('notes.md'));
|
||||
|
||||
await waitFor(() => {
|
||||
const md = screen.getByTestId('md');
|
||||
expect(md).toBeInTheDocument();
|
||||
expect(md.textContent).toContain('Hello heading');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -2,23 +2,27 @@ import { useFileManager, type FileManagerProps } from './useFileManager'
|
||||
import { ImageLightbox } from './FileManagerImageLightbox'
|
||||
import { AssignModal } from './FileManagerAssignModal'
|
||||
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
|
||||
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
|
||||
import { isMarkdown } from './FileManager.helpers'
|
||||
import { FileManagerToolbar } from './FileManagerToolbar'
|
||||
import { TrashView } from './FileManagerTrashView'
|
||||
import { FilesView } from './FileManagerFilesView'
|
||||
|
||||
export default function FileManager(props: FileManagerProps) {
|
||||
const S = useFileManager(props)
|
||||
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
|
||||
{/* Assign modal */}
|
||||
{assignFileId && <AssignModal {...S} />}
|
||||
|
||||
{/* PDF preview modal */}
|
||||
{previewFile && <PdfPreviewModal {...S} />}
|
||||
{/* Document preview modal (markdown is rendered inline; everything else PDF/object) */}
|
||||
{previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name)
|
||||
? <MarkdownPreviewModal {...S} />
|
||||
: <PdfPreviewModal {...S} />)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<FileManagerToolbar {...S} />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
import { triggerDownload, isVideo } from './FileManager.helpers'
|
||||
import VideoPlayer from '../Journey/VideoPlayer'
|
||||
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
@@ -20,10 +21,14 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
const fileIsVideo = isVideo(file?.mime_type)
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url])
|
||||
// Images use a one-shot signed URL; a video must use the plain same-origin
|
||||
// URL (cookie auth) so its many Range requests all authenticate (#823).
|
||||
if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url, file?.mime_type])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
@@ -98,7 +103,13 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{fileIsVideo ? (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
|
||||
)}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
@@ -115,14 +126,20 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
}
|
||||
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const fileIsVideo = isVideo(file.mime_type)
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||
// Videos have no stored thumbnail and can't render as an <img>; show a play
|
||||
// placeholder and don't mint a download token for them (#823).
|
||||
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
|
||||
}}>
|
||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||
{fileIsVideo
|
||||
? <Play size={16} fill="currentColor" />
|
||||
: (src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ExternalLink, Download, X } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
|
||||
/**
|
||||
* Inline preview for uploaded Markdown files (#1345). Fetches the file's text via
|
||||
* the signed preview URL and renders it with react-markdown. Output is sanitized
|
||||
* with rehype-sanitize — these are UNTRUSTED uploads, unlike collab notes — and
|
||||
* react-markdown v10 already drops raw HTML, so no script can execute.
|
||||
*/
|
||||
export function MarkdownPreviewModal(S: FileManagerState) {
|
||||
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||
const [text, setText] = useState('')
|
||||
const [err, setErr] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewFileUrl) return
|
||||
let cancelled = false
|
||||
setErr(false)
|
||||
setText('')
|
||||
fetch(previewFileUrl, { credentials: 'include' })
|
||||
.then(r => (r.ok ? r.text() : Promise.reject(new Error('load failed'))))
|
||||
.then(body => { if (!cancelled) setText(body) })
|
||||
.catch(() => { if (!cancelled) setErr(true) })
|
||||
return () => { cancelled = true }
|
||||
}, [previewFileUrl])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md" style={{ flex: 1, overflowY: 'auto', padding: '20px 28px', color: 'var(--text-primary)', lineHeight: 1.6, wordBreak: 'break-word' }}>
|
||||
{err
|
||||
? <p style={{ color: 'var(--text-muted)' }}>{t('files.openError')}</p>
|
||||
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage } from './FileManager.helpers'
|
||||
import { isImage, isMedia } from './FileManager.helpers'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
@@ -184,11 +184,12 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||
// Image OR video — both open in the lightbox; videos play there (#823).
|
||||
const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type))
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||
if (isMedia(file.mime_type)) {
|
||||
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
@@ -202,7 +203,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
|
||||
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
|
||||
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
|
||||
handleAssign, imageFiles, openFile,
|
||||
handleAssign, mediaFiles, openFile,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
|
||||
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, addonsApi } from '../../api/client'
|
||||
@@ -66,7 +67,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (!files?.length) return
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
// Videos skip HEIC normalization; only images are converted (#823).
|
||||
const all = Array.from(files)
|
||||
const videos = all.filter(isVideoFile)
|
||||
const images = all.filter(f => !isVideoFile(f))
|
||||
const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
@@ -110,7 +115,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
@@ -158,13 +163,26 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{photo.media_type === 'video' && !photo.thumbnail_path ? (
|
||||
// Poster-less video (capture failed / unsupported codec): show a
|
||||
// neutral tile rather than a broken 404 thumbnail (#823).
|
||||
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
|
||||
) : (
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
{photo.media_type === 'video' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
|
||||
<Play size={16} className="ml-0.5" fill="currentColor" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
|
||||
@@ -205,10 +223,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
for (const group of groups) {
|
||||
try {
|
||||
if (entryId) {
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
} else {
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
@@ -27,7 +27,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -123,7 +123,8 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -293,7 +294,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
@@ -396,13 +397,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
@@ -8,6 +9,7 @@ interface LightboxPhoto {
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
mediaType?: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -107,17 +109,21 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
{/* Photo or video */}
|
||||
{photo.mediaType === 'video' ? (
|
||||
<VideoPlayer key={photo.id} src={photo.src} />
|
||||
) : (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
/**
|
||||
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
|
||||
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and
|
||||
* the source carries the correct video MIME from the server. The Plyr instance is
|
||||
* created once per mounted source and destroyed on unmount, so navigating away in
|
||||
* the lightbox stops playback.
|
||||
*/
|
||||
export default function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
style,
|
||||
}: {
|
||||
src: string
|
||||
poster?: string
|
||||
autoPlay?: boolean
|
||||
style?: React.CSSProperties
|
||||
}): React.ReactElement {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
const player = new Plyr(el, {
|
||||
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
|
||||
autoplay: autoPlay,
|
||||
// Keep playback inline so the lightbox stays in control on mobile.
|
||||
clickToPlay: true,
|
||||
})
|
||||
return () => { try { player.destroy() } catch { /* already torn down */ } }
|
||||
}, [src, autoPlay])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 'min(92vw, 1100px)',
|
||||
maxHeight: '92vh',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
@@ -252,6 +252,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
|
||||
<Link to="/help" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('nav.help')}
|
||||
</Link>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
|
||||
@@ -7,16 +7,20 @@ vi.mock('../../sync/mutationQueue', () => ({
|
||||
mutationQueue: {
|
||||
pendingCount: vi.fn(),
|
||||
failedCount: vi.fn(),
|
||||
conflictCount: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { _resetNetworkMode } from '../../sync/networkMode'
|
||||
|
||||
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
||||
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
||||
const conflictCount = mutationQueue.conflictCount as ReturnType<typeof vi.fn>
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_resetNetworkMode()
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
@@ -24,15 +28,27 @@ describe('OfflineBanner (B3 surface)', () => {
|
||||
it('shows the failed pill when failedCount > 0 while online', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(2)
|
||||
conflictCount.mockResolvedValue(0)
|
||||
|
||||
render(<OfflineBanner />)
|
||||
|
||||
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/failed to sync: 2/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when online with nothing pending or failed', async () => {
|
||||
it('shows the conflict pill when conflicts exist while online', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(0)
|
||||
conflictCount.mockResolvedValue(3)
|
||||
|
||||
render(<OfflineBanner />)
|
||||
|
||||
expect(await screen.findByText(/conflicts: 3/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when online with nothing pending, failed or conflicting', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(0)
|
||||
conflictCount.mockResolvedValue(0)
|
||||
|
||||
const { container } = render(<OfflineBanner />)
|
||||
// Give the async poll a tick to resolve.
|
||||
|
||||
@@ -1,49 +1,44 @@
|
||||
/**
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
* online + 0 pending → hidden
|
||||
* Priority (highest first):
|
||||
* N failed → red pill "Failed to sync: N" (changes were dropped)
|
||||
* N conflicts → purple pill "Conflicts: N" (need resolving)
|
||||
* offline → amber pill "Offline" / "Offline mode" / "Offline · N queued"
|
||||
* online + N → blue pill "Syncing N…"
|
||||
* online + 0 → hidden
|
||||
*
|
||||
* Rendered as a small floating pill anchored to the bottom-center of the
|
||||
* viewport so it never competes with top navigation or sticky modal
|
||||
* headers. On mobile it hovers just above the bottom tab bar.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle, GitMerge } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced } = useNetworkMode()
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
const [conflictCount, setConflictCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const [n, failed] = await Promise.all([
|
||||
const [n, failed, conflicts] = await Promise.all([
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflictCount(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPendingCount(n)
|
||||
setFailedCount(failed)
|
||||
setConflictCount(conflicts)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
@@ -51,22 +46,34 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||
const hidden = !offline && pendingCount === 0 && failedCount === 0 && conflictCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
// Failed mutations are the most important signal — they mean data was dropped.
|
||||
// Conflicts come next (they still need a decision), then plain offline status.
|
||||
const failed = failedCount > 0
|
||||
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
const conflict = !failed && conflictCount > 0
|
||||
const bg = failed ? '#b91c1c' : conflict ? '#6d28d9' : offline ? '#92400e' : '#1e40af'
|
||||
|
||||
const label = failed
|
||||
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||
: offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
let label: string
|
||||
let icon: React.ReactElement
|
||||
if (failed) {
|
||||
label = t('settings.offline.banner.failed', { count: failedCount })
|
||||
icon = <AlertTriangle size={12} />
|
||||
} else if (conflict) {
|
||||
label = t('settings.offline.banner.conflicts', { count: conflictCount })
|
||||
icon = <GitMerge size={12} />
|
||||
} else if (offline) {
|
||||
label = pendingCount > 0
|
||||
? t('settings.offline.banner.queued', { count: pendingCount })
|
||||
: forced
|
||||
? t('settings.offline.banner.forced')
|
||||
: t('settings.offline.banner.offline')
|
||||
icon = <WifiOff size={12} />
|
||||
} else {
|
||||
label = t('settings.offline.banner.syncing', { count: pendingCount })
|
||||
icon = <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -81,7 +88,7 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
@@ -94,12 +101,7 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{failed
|
||||
? <AlertTriangle size={12} />
|
||||
: offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -244,4 +244,22 @@ describe('MapView', () => {
|
||||
rerender(<MapView places={places} fitKey={2} />)
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
|
||||
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
||||
const dayPlaces = [
|
||||
buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }),
|
||||
buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }),
|
||||
]
|
||||
// Day selected, route not computed yet → first fit is the two destinations.
|
||||
const { rerender } = render(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[]} fitKey={5} />)
|
||||
const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] }
|
||||
expect(lastBounds()).toHaveLength(2)
|
||||
|
||||
// The day's route arrives → one-shot re-fit including the 3 route points.
|
||||
L.latLngBounds.mockClear()
|
||||
rerender(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[[[47.9, 1.9], [48.05, 2.05], [48.2, 2.2]]]} fitKey={5} />)
|
||||
expect(L.latLngBounds).toHaveBeenCalled()
|
||||
expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points
|
||||
})
|
||||
})
|
||||
|
||||
@@ -212,24 +212,27 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
// Fit bounds when places change (fitKey triggers re-fit). On a day selection we
|
||||
// fit to that day's destinations immediately, then — once the day's route has
|
||||
// finished computing asynchronously — re-fit once more to include the full route
|
||||
// polyline, so a route that bulges past its stops stays in view (#1128).
|
||||
interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
routeCoords: [number, number][]
|
||||
fitKey: number
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fitKey === prevFitKey.current) return
|
||||
prevFitKey.current = fitKey
|
||||
if (places.length === 0) return
|
||||
const fitTo = useCallback((coords: [number, number][]) => {
|
||||
if (coords.length === 0) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
const bounds = L.latLngBounds(coords)
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (hasDayDetail) {
|
||||
@@ -237,8 +240,27 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts, hasDayDetail])
|
||||
|
||||
// New fitKey (initial trip fit or a day selection): fit to the destinations now
|
||||
// and arm a one-shot re-fit for when the route arrives.
|
||||
useEffect(() => {
|
||||
if (fitKey === prevFitKey.current) return
|
||||
prevFitKey.current = fitKey
|
||||
awaitingRoute.current = false
|
||||
if (places.length === 0) return
|
||||
fitTo(places.map(p => [p.lat, p.lng] as [number, number]))
|
||||
awaitingRoute.current = true
|
||||
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Once the just-selected day's route is ready, expand the fit to include it.
|
||||
// One-shot per day-fit, so later route-profile toggles don't re-zoom the map.
|
||||
useEffect(() => {
|
||||
if (!awaitingRoute.current || routeCoords.length === 0) return
|
||||
awaitingRoute.current = false
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -463,6 +485,9 @@ export const MapView = memo(function MapView({
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
|
||||
// include the full polyline once it has been computed.
|
||||
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
@@ -597,7 +622,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
|
||||
@@ -1496,4 +1496,35 @@ describe('PackingListPanel', () => {
|
||||
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
|
||||
it('FE-COMP-PACKING-080: the view switch separates the Common pool from My list', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
|
||||
const items = [
|
||||
buildPackingItem({ name: 'Group tent', is_private: 0 }),
|
||||
buildPackingItem({ name: 'My diary', is_private: 1, owner_id: 1 }),
|
||||
];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
|
||||
// Default view = Common pool → only the shared item.
|
||||
expect(await screen.findByText('Group tent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('My diary')).not.toBeInTheDocument();
|
||||
|
||||
// Switch to "My list" → only the personal item.
|
||||
await userEvent.click(screen.getByText('My list'));
|
||||
expect(await screen.findByText('My diary')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Group tent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-081: a shared-to-me item shows the "by <bringer>" badge in My list', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
|
||||
const items = [
|
||||
buildPackingItem({ name: 'Power bank', is_private: 1, owner_id: 2, owner_username: 'Bob', recipients: [{ user_id: 1, username: 'me' }] }),
|
||||
];
|
||||
render(<PackingListPanel tripId={1} items={items} />);
|
||||
await userEvent.click(screen.getByText('My list'));
|
||||
await screen.findByText('Power bank');
|
||||
// "by Bob" — taken care of by the bringer.
|
||||
expect(screen.getByText('by Bob')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { usePackingList } from './usePackingListPanel'
|
||||
import type { PackingListPanelProps } from './usePackingListPanel'
|
||||
import { PackingHeader } from './PackingListPanelHeader'
|
||||
import { PackingViewTabs } from './PackingListPanelViewTabs'
|
||||
import { PackingFilterTabs } from './PackingListPanelFilterTabs'
|
||||
import { PackingList } from './PackingListPanelList'
|
||||
import { BagSidebar } from './PackingListPanelBagSidebar'
|
||||
@@ -18,6 +19,9 @@ export default function PackingListPanel(props: PackingListPanelProps) {
|
||||
{/* ── Header ── */}
|
||||
<PackingHeader {...S} />
|
||||
|
||||
{/* ── View-Switch: Gemeinsam / Meine Liste (#858) ── */}
|
||||
<PackingViewTabs {...S} />
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
<PackingFilterTabs {...S} />
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
import type { TripMember, CategoryAssignee } from './usePackingListPanel'
|
||||
import { ArtikelZeile } from './PackingListPanelItemRow'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
|
||||
interface KategorieGruppeProps {
|
||||
kategorie: string
|
||||
@@ -27,10 +28,40 @@ interface KategorieGruppeProps {
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
// Drag-to-reorder (#969): the full ordered item list + a persist callback. The
|
||||
// order is global, so a within-category drag is mapped back onto the full list.
|
||||
allItems: PackingItem[]
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
onClone?: (id: number) => void
|
||||
onJoin?: (id: number) => void
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
|
||||
const handleReorderDrop = (targetId: number) => {
|
||||
const from = dragId
|
||||
setDragId(null); setOverId(null)
|
||||
if (from == null || from === targetId) return
|
||||
const catOrder = items.map(i => i.id)
|
||||
const fi = catOrder.indexOf(from)
|
||||
const ti = catOrder.indexOf(targetId)
|
||||
if (fi < 0 || ti < 0) return
|
||||
catOrder.splice(fi, 1)
|
||||
catOrder.splice(ti, 0, from)
|
||||
// Slot the reordered category ids back into the positions this category's
|
||||
// items occupy in the global list, leaving every other category untouched.
|
||||
const catIds = new Set(items.map(i => i.id))
|
||||
let ci = 0
|
||||
const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id))
|
||||
onReorder(globalIds)
|
||||
}
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -182,7 +213,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
}}>
|
||||
{m.username[0]}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{m.username}</span>
|
||||
<span style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
|
||||
{m.is_guest && <GuestBadge size="xs" />}
|
||||
</span>
|
||||
{isAssigned && <Check size={12} className="text-content-muted" />}
|
||||
</button>
|
||||
)
|
||||
@@ -232,7 +266,16 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
|
||||
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
||||
drag={canEdit ? {
|
||||
isDragging: dragId === item.id,
|
||||
isOver: overId === item.id && dragId !== null && dragId !== item.id,
|
||||
onStart: (id) => { setDragId(id); setOverId(null) },
|
||||
onOver: (id) => setOverId(id),
|
||||
onEnd: () => { setDragId(null); setOverId(null) },
|
||||
onDrop: handleReorderDrop,
|
||||
} : undefined} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{canEdit && (showAddItem ? (
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
CheckSquare, Square, Trash2, Plus, Pencil, Package,
|
||||
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { QuantityInput } from './PackingListPanelQuantityInput'
|
||||
import PackingShareControl from './PackingShareControl'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
@@ -20,9 +22,25 @@ interface ArtikelZeileProps {
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
// Three-tier sharing (#858): members + handlers for the per-item share control.
|
||||
tripMembers?: TripMember[]
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
onClone?: (id: number) => void
|
||||
onJoin?: (id: number) => void
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
|
||||
drag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: (id: number) => void
|
||||
onOver: (id: number) => void
|
||||
onEnd: () => void
|
||||
onDrop: (targetId: number) => void
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
@@ -35,6 +53,14 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Three-tier sharing display (#858).
|
||||
const sharedToMe = !!item.is_private && item.owner_id != null && item.owner_id !== currentUserId
|
||||
const recipients = item.recipients || []
|
||||
const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0
|
||||
const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null
|
||||
const contributors = item.contributors || []
|
||||
const canShare = canEdit && !isPlaceholder && !!onSetSharing
|
||||
|
||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
@@ -58,18 +84,36 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
|
||||
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
|
||||
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
|
||||
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
||||
background: hovered ? 'var(--bg-secondary)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{canDrag && (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
|
||||
onDragEnd={() => drag!.onEnd()}
|
||||
title=""
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, opacity: hovered ? 1 : 0.35, transition: 'opacity 0.15s' }}
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||
width: 18, height: 18,
|
||||
@@ -114,6 +158,26 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sharing badges (#858 three-tier) */}
|
||||
{!isPlaceholder && sharedToMe && (
|
||||
<span title={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)', padding: '1px 7px', borderRadius: 99 }}>
|
||||
<HandHelping size={10} /> {t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && sharedByMe && (
|
||||
<span title={recipients.map(r => r.username).join(', ')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}>
|
||||
<UserRound size={10} /> {t('packing.sharedWithCount', { count: recipients.length })}
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span title={t('packing.broughtBy', { name: broughtBy })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '1px 4px' }}>
|
||||
<Users size={10} /> {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
|
||||
|
||||
@@ -245,6 +309,18 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canShare && onClone && onJoin && onLeave && (
|
||||
<PackingShareControl
|
||||
item={item}
|
||||
tripMembers={tripMembers}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={onSetSharing!}
|
||||
onClone={onClone}
|
||||
onJoin={onJoin}
|
||||
onLeave={onLeave}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={13} />
|
||||
|
||||
@@ -6,7 +6,8 @@ export function PackingList(S: PackingState) {
|
||||
const {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
} = S
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
@@ -40,6 +41,13 @@ export function PackingList(S: PackingState) {
|
||||
bags={bags}
|
||||
onCreateBag={handleCreateBagByName}
|
||||
canEdit={canEdit}
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
onJoin={handleJoinItem}
|
||||
onLeave={handleLeaveItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Users, UserRound } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
/**
|
||||
* Top-level switch between the shared group pool ("Gemeinsam") and the traveler's
|
||||
* own list ("Meine Liste" — private + items shared to them) — the #858 three-tier
|
||||
* model. Existing items live in the Common pool, so that stays the default.
|
||||
*/
|
||||
export function PackingViewTabs(S: PackingState) {
|
||||
const { view, setView, t, items } = S
|
||||
const commonCount = items.filter(i => !i.is_private).length
|
||||
const personalCount = items.filter(i => !!i.is_private).length
|
||||
|
||||
const tab = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
|
||||
const active = view === id
|
||||
return (
|
||||
<button onClick={() => setView(id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', borderRadius: 999,
|
||||
border: '1px solid', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
background: active ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-secondary)',
|
||||
transition: 'all 0.12s',
|
||||
}}>
|
||||
{icon}{label}
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, borderRadius: 99, padding: '0 6px',
|
||||
background: active ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '10px 16px 0', flexShrink: 0 }}>
|
||||
{tab('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
|
||||
{tab('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Users, UserRound, Share2, Check, Copy, HandHelping } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { PackingItem } from '../../types'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
|
||||
interface Props {
|
||||
item: PackingItem
|
||||
tripMembers: TripMember[]
|
||||
currentUserId?: number
|
||||
onSetSharing: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
onClone: (id: number) => void
|
||||
onJoin: (id: number) => void
|
||||
onLeave: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-item sharing control for the three-tier packing model (#858). The owner
|
||||
* (bringer) sets the tier — Common / Personal / Shared with specific people — via
|
||||
* a dropdown; everyone else can pledge to co-bring a Common item ("I can bring
|
||||
* that too") or clone it onto their own list.
|
||||
*/
|
||||
export default function PackingShareControl({ item, tripMembers, currentUserId, onSetSharing, onClone, onJoin, onLeave }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [open])
|
||||
|
||||
const isCommon = !item.is_private
|
||||
const isOwner = item.owner_id == null || item.owner_id === currentUserId
|
||||
const recipientIds = (item.recipients || []).map(r => r.user_id)
|
||||
const visibility: 'common' | 'personal' | 'shared' = isCommon ? 'common' : recipientIds.length > 0 ? 'shared' : 'personal'
|
||||
const iAmContributor = (item.contributors || []).some(c => c.user_id === currentUserId)
|
||||
const others = tripMembers.filter(m => m.id !== item.owner_id && m.id !== currentUserId)
|
||||
|
||||
const toggleRecipient = (uid: number) => {
|
||||
const next = recipientIds.includes(uid) ? recipientIds.filter(x => x !== uid) : [...recipientIds, uid]
|
||||
onSetSharing(item.id, 'shared', next)
|
||||
}
|
||||
|
||||
const btn = (onClick: () => void, title: string, active: boolean, node: React.ReactNode) => (
|
||||
<button onClick={onClick} title={title}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: active ? 'var(--accent)' : 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-secondary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{node}
|
||||
</button>
|
||||
)
|
||||
|
||||
// Non-owner on a Common item: pledge to co-bring + clone to personal list.
|
||||
if (!isOwner && isCommon) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{btn(() => (iAmContributor ? onLeave(item.id, currentUserId!) : onJoin(item.id)),
|
||||
iAmContributor ? t('packing.alsoBringingStop') : t('packing.alsoBring'), iAmContributor, <HandHelping size={14} />)}
|
||||
{btn(() => onClone(item.id), t('packing.cloneToMine'), false, <Copy size={13} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A recipient of a shared item has no controls (it's the owner's responsibility).
|
||||
if (!isOwner) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
{btn(() => setOpen(o => !o), t('packing.share'), visibility !== 'common', <Share2 size={14} />)}
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 60,
|
||||
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: 190,
|
||||
}}>
|
||||
<Row icon={<Users size={13} />} label={t('packing.viewCommon')} sub={t('packing.tierCommonHint')} active={visibility === 'common'} onClick={() => { onSetSharing(item.id, 'common', []); setOpen(false) }} />
|
||||
<Row icon={<UserRound size={13} />} label={t('packing.tierPersonal')} sub={t('packing.tierPersonalHint')} active={visibility === 'personal'} onClick={() => { onSetSharing(item.id, 'personal', []); setOpen(false) }} />
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<div style={{ padding: '4px 10px 2px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<Share2 size={10} /> {t('packing.tierShared')}
|
||||
</div>
|
||||
{others.length === 0 ? (
|
||||
<div style={{ padding: '4px 10px 6px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noOneToShare')}</div>
|
||||
) : others.map(m => {
|
||||
const on = recipientIds.includes(m.id)
|
||||
return (
|
||||
<button key={m.id} onClick={() => toggleRecipient(m.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: 'none', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', flexShrink: 0, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase' }}>{m.username[0]}</span>
|
||||
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
|
||||
{on && <Check size={13} className="text-content-muted" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; label: string; sub: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', fontFamily: 'inherit', textAlign: 'left' }}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = active ? 'var(--bg-tertiary)' : 'none' }}>
|
||||
<span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', marginTop: 1 }}>{icon}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<span style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{sub}</span>
|
||||
</span>
|
||||
{active && <Check size={13} className="text-content-muted" style={{ marginTop: 2 }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -16,12 +16,14 @@ export interface TripMember {
|
||||
username: string
|
||||
avatar?: string | null
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryAssignee {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface PackingListPanelProps {
|
||||
@@ -42,13 +44,18 @@ export interface PackingListPanelProps {
|
||||
*/
|
||||
export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
// Three-tier sharing (#858): 'common' = the group pool (where existing items
|
||||
// live — non-breaking), 'personal' = my own list (private + shared-to-me).
|
||||
const [view, setView] = useState<'common' | 'personal'>('common')
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
|
||||
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const currentUserId = useAuthStore((s) => s.user?.id)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -59,8 +66,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
useEffect(() => {
|
||||
tripsApi.getMembers(tripId).then(data => {
|
||||
const all: TripMember[] = []
|
||||
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url })
|
||||
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url })))
|
||||
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
|
||||
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
|
||||
setTripMembers(all)
|
||||
}).catch(() => {})
|
||||
packingApi.getCategoryAssignees(tripId).then(data => {
|
||||
@@ -77,17 +84,24 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
}
|
||||
}
|
||||
|
||||
// Split by the active view (#858): Common = group pool (is_private 0), Personal =
|
||||
// my own + shared-to-me (is_private 1, already filtered to me by the server).
|
||||
const viewItems = useMemo(
|
||||
() => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)),
|
||||
[items, view],
|
||||
)
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const seen: string[] = []
|
||||
for (const item of items) {
|
||||
for (const item of viewItems) {
|
||||
const cat = item.category || t('packing.defaultCategory')
|
||||
if (!seen.includes(cat)) seen.push(cat)
|
||||
}
|
||||
return seen
|
||||
}, [items, t])
|
||||
}, [viewItems, t])
|
||||
|
||||
const gruppiert = useMemo(() => {
|
||||
const filtered = items.filter(i => {
|
||||
const filtered = viewItems.filter(i => {
|
||||
if (filter === 'offen') return !i.checked
|
||||
if (filter === 'erledigt') return i.checked
|
||||
return true
|
||||
@@ -99,10 +113,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
groups[kat].push(item)
|
||||
}
|
||||
return groups
|
||||
}, [items, filter, t])
|
||||
}, [viewItems, filter, t])
|
||||
|
||||
const abgehakt = items.filter(i => i.checked).length
|
||||
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
|
||||
const abgehakt = viewItems.filter(i => i.checked).length
|
||||
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
|
||||
|
||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
@@ -115,7 +129,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
if (placeholder) {
|
||||
await updatePackingItem(tripId, placeholder.id, { name })
|
||||
} else {
|
||||
await addPackingItem(tripId, { name, category })
|
||||
// New items inherit the active view's tier: Personal in "my list", Common otherwise.
|
||||
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
@@ -153,7 +168,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
catName += ''
|
||||
}
|
||||
try {
|
||||
await addPackingItem(tripId, { name: '...', category: catName })
|
||||
await addPackingItem(tripId, { name: '...', category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
|
||||
setNewCatName('')
|
||||
setAddingCategory(false)
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
@@ -339,8 +354,17 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
|
||||
const font = { fontFamily: "var(--font-system)" }
|
||||
|
||||
// ── Three-tier sharing handlers (#858) ──────────────────────────────────────
|
||||
const handleSetSharing = (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) =>
|
||||
setPackingItemSharing(tripId, id, visibility, recipientIds)
|
||||
const handleCloneItem = (id: number) => clonePackingItem(tripId, id)
|
||||
const handleJoinItem = (id: number) => addPackingContributor(tripId, id)
|
||||
const handleLeaveItem = (id: number, userId: number) => removePackingContributor(tripId, id, userId)
|
||||
|
||||
return {
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||
view, setView, currentUserId,
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
|
||||
@@ -909,7 +909,7 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS export click ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-058: ICS menu "Download ICS" calls fetch for .ics export', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -919,7 +919,10 @@ describe('DayPlanSidebar', () => {
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
// The ICS button now opens a hover menu (Download / Subscribe) instead of
|
||||
// downloading on direct click.
|
||||
await user.hover(screen.getByText('ICS').closest('button')!)
|
||||
await user.click(await screen.findByText('Download ICS'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
@@ -1550,14 +1553,14 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS hover tooltip ─────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows tooltip', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const icsBtn = screen.getByRole('button', { name: /ICS/i })
|
||||
const icsBtn = screen.getByText('ICS').closest('button')!
|
||||
await user.hover(icsBtn)
|
||||
await waitFor(() => {
|
||||
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
|
||||
expect(tooltips.length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
@@ -36,11 +37,35 @@ interface DayPlanSidebarToolbarProps {
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
setIcsMenuVisible(true)
|
||||
setIcsHover(true)
|
||||
}
|
||||
const hideIcsMenu = () => {
|
||||
icsMenuTimeoutRef.current = setTimeout(() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -83,25 +108,12 @@ export function DayPlanSidebarToolbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
@@ -113,19 +125,66 @@ export function DayPlanSidebarToolbar({
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.icsTooltip')}
|
||||
{icsMenuVisible && (
|
||||
<div
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0,
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, RefreshCw, Calendar, Power } from 'lucide-react'
|
||||
import { SubscribeLinks } from './SubscribeLinks'
|
||||
|
||||
interface IcsSubscribeModalProps {
|
||||
/** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */
|
||||
endpoint: string
|
||||
title: string
|
||||
description: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// A server that has no APP_URL configured hands back a host-relative path; the
|
||||
// webcal:// handoff and Google deep link need an absolute URL, so resolve it
|
||||
// against the current origin as a fallback.
|
||||
function absolutize(url: string): string {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (url.startsWith('/')) return window.location.origin + url
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it
|
||||
* only *reads* the current token — it never mints one silently. The user
|
||||
* explicitly enables the public link, and can rotate or fully turn it off.
|
||||
*/
|
||||
export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) {
|
||||
const tokenUrl = `${endpoint}/token`
|
||||
const [feedUrl, setFeedUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const httpsUrl = feedUrl ? absolutize(feedUrl) : ''
|
||||
const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : ''
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(tokenUrl, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [tokenUrl])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch(tokenUrl, { method, credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { feed_url: string | null }
|
||||
setFeedUrl(data.feed_url)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 14,
|
||||
padding: '22px 24px',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
|
||||
color: 'var(--text-muted)', borderRadius: 6, display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
Loading…
|
||||
</div>
|
||||
) : !feedUrl ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => mutate('POST')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
width: '100%', padding: '9px 14px', borderRadius: 9, border: 'none',
|
||||
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} strokeWidth={2} />
|
||||
Enable calendar subscription
|
||||
</button>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
|
||||
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => mutate('PUT')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 7, padding: '5px 10px',
|
||||
fontSize: 11, color: 'var(--text-muted)',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutate('DELETE')}
|
||||
disabled={busy}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 7, padding: '5px 10px',
|
||||
fontSize: 11, color: 'var(--danger, #dc2626)',
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Power size={11} strokeWidth={2} />
|
||||
Turn off
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -91,6 +92,7 @@ interface TripMember {
|
||||
username: string
|
||||
avatar?: string | null
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
@@ -486,7 +488,8 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
}}>
|
||||
{(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}
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{member.username}</span>
|
||||
{member.is_guest && <GuestBadge size="xs" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, MapPin } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Category } from '../../types'
|
||||
|
||||
interface PlacesBulkCategoryModalProps {
|
||||
count: number
|
||||
categories: Category[]
|
||||
onPick: (categoryId: number | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||
padding: '8px 10px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', textAlign: 'left',
|
||||
}
|
||||
const hoverOn = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'var(--bg-hover)' }
|
||||
const hoverOff = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'transparent' }
|
||||
|
||||
/**
|
||||
* Popup for the Places selection toolbar: pick one category to apply to every
|
||||
* currently-selected place. Reuses the category swatch styling from the header's
|
||||
* filter dropdown; clicking a row applies immediately and closes.
|
||||
*/
|
||||
export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }: PlacesBulkCategoryModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div className="bg-surface-card text-content" style={{
|
||||
borderRadius: 14, padding: '18px 20px', width: '100%', maxWidth: 380,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', border: '1px solid var(--border-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('places.changeCategory')}</span>
|
||||
<button onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 12, marginBottom: 12 }}>{t('places.selectionCount', { count })}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<button key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<CatIcon size={14} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PlacesSelectionBar } from './PlacesSidebarSelectionBar'
|
||||
import { PlacesList } from './PlacesSidebarList'
|
||||
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
|
||||
import { ListImportModal } from './PlacesSidebarListImportModal'
|
||||
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
|
||||
const S = usePlacesSidebar(props)
|
||||
@@ -16,6 +17,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
selectMode, filtered, t, dayPickerPlace, listImportOpen,
|
||||
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
|
||||
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
|
||||
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
|
||||
} = S
|
||||
return (
|
||||
<div
|
||||
@@ -51,6 +53,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{categoryPickerOpen && (
|
||||
<PlacesBulkCategoryModal
|
||||
count={selectedIds.size}
|
||||
categories={categories}
|
||||
onClose={() => setCategoryPickerOpen(false)}
|
||||
onPick={(catId) => { onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Check, Trash2 } from 'lucide-react'
|
||||
import { Check, Tag, Trash2 } from 'lucide-react'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function PlacesSelectionBar(S: SidebarState) {
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces } = S
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen } = S
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
@@ -32,6 +32,23 @@ export function PlacesSelectionBar(S: SidebarState) {
|
||||
<Check size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.changeCategory')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { if (selectedIds.size === 0) return; setCategoryPickerOpen(true) }}
|
||||
disabled={selectedIds.size === 0}
|
||||
aria-label={t('places.changeCategory')}
|
||||
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -86,7 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_address: '',
|
||||
@@ -136,6 +136,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
url: reservation.url || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
@@ -164,6 +165,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
location: prefill.location || '',
|
||||
confirmation_number: prefill.confirmation_number || '',
|
||||
notes: prefill.notes || '',
|
||||
url: (prefill as { url?: string }).url || '',
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
@@ -180,7 +182,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
|
||||
})
|
||||
@@ -237,6 +239,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
url: form.url,
|
||||
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
@@ -591,6 +594,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.urlLabel')}</label>
|
||||
<div className="relative">
|
||||
<Link2 size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
||||
<input type="url" value={form.url} onChange={e => set('url', e.target.value)}
|
||||
placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.notes')}</label>
|
||||
|
||||
@@ -361,6 +361,18 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
{r.url && (
|
||||
<div>
|
||||
<div className={fieldLabelClass}>{t('reservations.urlLabel')}</div>
|
||||
<div className={fieldValueClass} style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<ExternalLink size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.url}</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check, CalendarPlus, Calendar } from 'lucide-react'
|
||||
|
||||
interface SubscribeLinksProps {
|
||||
httpsUrl: string
|
||||
webcalUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared presentation for calendar subscription URLs. Renders one-click
|
||||
* subscribe actions (Google deep link + webcal handoff) plus a copy fallback.
|
||||
* Used by both the per-trip and all-trips subscribe modals.
|
||||
*/
|
||||
export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) {
|
||||
const [copied, setCopied] = useState<'https' | 'webcal' | null>(null)
|
||||
|
||||
// Google Calendar's add-by-URL deep link. The cid must carry the webcal://
|
||||
// scheme (not https), URL-encoded, and the feed must be served over HTTPS.
|
||||
const googleUrl = `https://www.google.com/calendar/render?cid=${encodeURIComponent(webcalUrl)}`
|
||||
|
||||
const copy = async (url: string, which: 'https' | 'webcal') => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else {
|
||||
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = url
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
setCopied(which)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* One-click subscribe actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
<a
|
||||
href={googleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
|
||||
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={14} strokeWidth={2} />
|
||||
Add to Google Calendar
|
||||
</a>
|
||||
<a
|
||||
href={webcalUrl}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
|
||||
background: 'none', border: '1px solid var(--border-primary)',
|
||||
color: 'var(--text-primary)', fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} strokeWidth={2} />
|
||||
Add to Apple Calendar / Outlook
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Manual fallback — raw URLs for any other client / "From URL" boxes */}
|
||||
<details style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<summary style={{ cursor: 'pointer', userSelect: 'none', marginBottom: 8 }}>
|
||||
Or copy a link manually
|
||||
</summary>
|
||||
<UrlRow
|
||||
label="Google Calendar"
|
||||
hint="paste into “From URL”"
|
||||
url={httpsUrl}
|
||||
copied={copied === 'https'}
|
||||
onCopy={() => copy(httpsUrl, 'https')}
|
||||
/>
|
||||
<UrlRow
|
||||
label="Apple Calendar / Outlook"
|
||||
hint="webcal://"
|
||||
url={webcalUrl}
|
||||
copied={copied === 'webcal'}
|
||||
onCopy={() => copy(webcalUrl, 'webcal')}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlRow({ label, hint, url, copied, onCopy }: {
|
||||
label: string; hint: string; url: string; copied: boolean; onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, marginBottom: 5, color: 'var(--text-primary)' }}>
|
||||
{label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>— {hint}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<div style={{
|
||||
flex: 1, fontSize: 10, fontFamily: 'monospace',
|
||||
padding: '5px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-subtle, #f9fafb)',
|
||||
color: 'var(--text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{url}
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title="Copy"
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
cursor: 'pointer', color: copied ? 'var(--accent, #6366f1)' : 'var(--text-muted)',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface PlacesSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -147,6 +148,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
@@ -258,6 +260,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
* Offline settings tab (#1135) — controls for:
|
||||
* - Offline mode: a force-offline switch that first downloads everything, then
|
||||
* routes the app to the cache + mutation queue.
|
||||
* - Prepare for offline: an awaited, progress-tracked full download.
|
||||
* - What to store: a map-tiles toggle plus a per-trip on/off.
|
||||
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
|
||||
* - Cache stats + clear.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
@@ -18,24 +34,43 @@ interface CachedTripRow {
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
const server = (m.conflictServer ?? {}) as { name?: unknown }
|
||||
return (typeof body.name === 'string' && body.name)
|
||||
|| (typeof server.name === 'string' && server.name)
|
||||
|| `#${m.entityId ?? ''}`
|
||||
}
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed] = await Promise.all([
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
@@ -49,6 +84,18 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -56,6 +103,29 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
@@ -67,7 +137,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
|
||||
if (!window.confirm(t('settings.offline.clearConfirm'))) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
@@ -77,104 +147,272 @@ export default function OfflineTab(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
|
||||
const progressLabel = progress
|
||||
? `${t(`settings.offline.prepare.phase.${progress.phase === 'done' ? 'trips' : progress.phase}`)} · ${progress.current}/${progress.total}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<Section title="Offline Cache" icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
{/* Offline mode + prepare */}
|
||||
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
label={t('settings.offline.mode.force')}
|
||||
hint={t('settings.offline.mode.forceHint')}
|
||||
control={<ToggleSwitch on={forced} onToggle={handleToggleForce} label={t('settings.offline.mode.force')} />}
|
||||
/>
|
||||
{forced && (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.mode.active')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || !navigator.onLine}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear cache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
No trips cached yet. Connect to internet to sync.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="border border-edge bg-surface-secondary"
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 4 }} className="text-content">
|
||||
{t('settings.offline.prepare.title')}
|
||||
</div>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 0, marginBottom: 12 }}>
|
||||
{t('settings.offline.prepare.hint')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={runPrepare}
|
||||
disabled={preparing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(preparing || offline)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
{preparing
|
||||
? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
: <Download size={14} />}
|
||||
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || offline}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={btnStyle(syncing || offline)}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
||||
</button>
|
||||
</div>
|
||||
{preparing && progress && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 6, borderRadius: 3, overflow: 'hidden', background: 'var(--border-primary, #e5e7eb)' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: 'var(--accent, #4F46E5)',
|
||||
width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%`,
|
||||
transition: 'width 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>
|
||||
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Conflicts (only when there are any) */}
|
||||
{conflicts.length > 0 && (
|
||||
<Section title={t('settings.offline.conflicts.title')} icon={GitMerge}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', margin: 0 }}>
|
||||
{t('settings.offline.conflicts.hint')}
|
||||
</p>
|
||||
{conflicts.map(c => (
|
||||
<div key={c.id} className="border border-edge bg-surface-secondary" style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
|
||||
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => resolveConflict(c.id, true)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
{t('settings.offline.conflicts.keepMine')}
|
||||
</button>
|
||||
<button onClick={() => resolveConflict(c.id, false)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
|
||||
{t('settings.offline.conflicts.keepServer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Row
|
||||
label={t('settings.offline.conflicts.strategyTitle')}
|
||||
control={
|
||||
<select
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<option value="ask">{t('settings.offline.conflicts.strategy.ask')}</option>
|
||||
<option value="mine">{t('settings.offline.conflicts.strategy.mine')}</option>
|
||||
<option value="server">{t('settings.offline.conflicts.strategy.server')}</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row
|
||||
label={t('settings.offline.storage.tiles')}
|
||||
hint={t('settings.offline.storage.tilesHint')}
|
||||
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
{allTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
|
||||
{t('settings.offline.storage.tripsTitle')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Cache stats + list + clear */}
|
||||
<Section title={t('settings.offline.cache.title')} icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
|
||||
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
|
||||
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
|
||||
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || rows.length === 0)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="border border-edge bg-surface-secondary"
|
||||
style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}{placeCount}{' · '}{fileCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function btnStyle(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 8,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: disabled ? 0.5 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function smallBtnStyle(): React.CSSProperties {
|
||||
return {
|
||||
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ label, hint, control }: { label: string; hint?: string; control: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontWeight: 500, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{label}</div>
|
||||
{hint && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{hint}</div>}
|
||||
</div>
|
||||
</Section>
|
||||
<div style={{ flexShrink: 0 }}>{control}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-edge bg-surface-secondary" style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
minWidth: 100,
|
||||
}}>
|
||||
<div className="border border-edge bg-surface-secondary" style={{ padding: '8px 14px', borderRadius: 8, minWidth: 100 }}>
|
||||
<div style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{label}</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import TodoRow from './TodoRow'
|
||||
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
|
||||
// Layout component: state/effects/derived/handlers live in useTodoList.
|
||||
const {
|
||||
canEdit, t, formatDate, toggleTodoItem,
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
@@ -31,6 +31,31 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
addCategory, catCount,
|
||||
} = useTodoList(tripId, items, addItemSignal)
|
||||
|
||||
// Drag-to-reorder (#969). Manual ordering only makes sense when the list isn't
|
||||
// sorted by priority; a drag within the filtered view is mapped back onto the
|
||||
// full item order so unfiltered tasks keep their place.
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
const canReorder = canEdit && !sortByPrio
|
||||
|
||||
const handleReorderDrop = (targetId: number) => {
|
||||
const from = dragId
|
||||
setDragId(null); setOverId(null)
|
||||
if (from == null || from === targetId) return
|
||||
const viewOrder = filtered.map(i => i.id)
|
||||
const fi = viewOrder.indexOf(from)
|
||||
const ti = viewOrder.indexOf(targetId)
|
||||
if (fi < 0 || ti < 0) return
|
||||
viewOrder.splice(fi, 1)
|
||||
viewOrder.splice(ti, 0, from)
|
||||
// Slot the reordered visible ids back into the positions they occupy in the
|
||||
// global list, leaving every filtered-out task where it was.
|
||||
const viewIds = new Set(filtered.map(i => i.id))
|
||||
let vi = 0
|
||||
const globalIds = items.map(i => (viewIds.has(i.id) ? viewOrder[vi++] : i.id))
|
||||
reorderTodoItems(tripId, globalIds)
|
||||
}
|
||||
|
||||
// Sidebar filter item
|
||||
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
|
||||
<button onClick={() => setFilter(id as FilterType)}
|
||||
@@ -189,6 +214,14 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
formatDate={formatDate}
|
||||
onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }}
|
||||
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
|
||||
drag={canReorder ? {
|
||||
isDragging: dragId === item.id,
|
||||
isOver: overId === item.id && dragId !== null && dragId !== item.id,
|
||||
onStart: (id) => { setDragId(id); setOverId(null) },
|
||||
onOver: (id) => setOverId(id),
|
||||
onEnd: () => { setDragId(null); setOverId(null) },
|
||||
onDrop: handleReorderDrop,
|
||||
} : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -446,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} className="text-content-faint" /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
label: m.is_guest ? `${m.username} · ${t('members.guest')}` : m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CheckSquare, Square, ChevronRight, Flag, Calendar } from 'lucide-react'
|
||||
import { CheckSquare, Square, ChevronRight, Flag, Calendar, GripVertical, UserRound } from 'lucide-react'
|
||||
import type { TodoItem } from '../../types'
|
||||
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
|
||||
|
||||
/** A single task row in the todo list. Pure presentation; all behaviour is
|
||||
* delegated to onSelect/onToggle so TodoListPanel stays a layout component. */
|
||||
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle }: {
|
||||
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle, drag }: {
|
||||
item: TodoItem
|
||||
members: Member[]
|
||||
categories: string[]
|
||||
@@ -14,24 +14,51 @@ export default function TodoRow({ item, members, categories, today, isSelected,
|
||||
formatDate: (d: string) => string
|
||||
onSelect: (id: number | null) => void
|
||||
onToggle: (id: number, checked: boolean) => void
|
||||
// Drag-to-reorder (#969); only provided when manual ordering is active.
|
||||
drag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: (id: number) => void
|
||||
onOver: (id: number) => void
|
||||
onEnd: () => void
|
||||
onDrop: (targetId: number) => void
|
||||
}
|
||||
}) {
|
||||
const done = !!item.checked
|
||||
const assignedUser = members.find(m => m.id === item.assigned_user_id)
|
||||
const isOverdue = item.due_date && !done && item.due_date < today
|
||||
const catColor = item.category ? katColor(item.category, categories) : null
|
||||
const canDrag = canEdit && !!drag
|
||||
|
||||
return (
|
||||
<div key={item.id}
|
||||
onClick={() => onSelect(isSelected ? null : item.id)}
|
||||
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
|
||||
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
|
||||
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
|
||||
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
|
||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
|
||||
{canDrag && (
|
||||
<div
|
||||
draggable
|
||||
onClick={e => e.stopPropagation()}
|
||||
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
|
||||
onDragEnd={() => drag!.onEnd()}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginLeft: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
<button onClick={e => { e.stopPropagation(); if (canEdit) onToggle(item.id, !done) }}
|
||||
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
|
||||
@@ -104,6 +131,7 @@ export default function TodoRow({ item, members, categories, today, isSelected,
|
||||
{assignedUser.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser.is_guest && <UserRound size={11} style={{ opacity: 0.7 }} />}
|
||||
{assignedUser.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -21,4 +21,4 @@ export function katColor(kat: string, allCategories: string[]) {
|
||||
|
||||
export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
||||
|
||||
export interface Member { id: number; username: string; avatar: string | null }
|
||||
export interface Member { id: number; username: string; avatar: string | null; is_guest?: boolean }
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { FilterType, Member } from './todoListModel'
|
||||
* (TodoRow) and the detail/new panes from this state.
|
||||
*/
|
||||
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem, reorderTodoItems } = useTripStore()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const can = useCanDo()
|
||||
const canEdit = can('packing_edit', trip)
|
||||
@@ -100,7 +100,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
|
||||
|
||||
return {
|
||||
canEdit, t, formatDate, toggleTodoItem,
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
|
||||
@@ -423,4 +423,42 @@ describe('TripMembersModal', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await screen.findByText('All users already have access.');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => {
|
||||
let createdName: string | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/guests', async ({ request }) => {
|
||||
createdName = ((await request.json()) as { name: string }).name;
|
||||
return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } });
|
||||
}),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
// The guests section + add affordance is shown to the owner.
|
||||
await screen.findByText('Guests');
|
||||
const input = screen.getByPlaceholderText('Guest name');
|
||||
await userEvent.type(input, 'Grandpa');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add guest/i }));
|
||||
await waitFor(() => expect(createdName).toBe('Grandpa'));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/members', () =>
|
||||
HttpResponse.json({
|
||||
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false },
|
||||
members: [
|
||||
{ id: 2, username: 'alice', avatar_url: null, is_guest: false },
|
||||
{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true },
|
||||
],
|
||||
current_user_id: ownerUser.id,
|
||||
})
|
||||
),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await screen.findByText('Grandma');
|
||||
// The guest carries a "Guest" badge.
|
||||
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
|
||||
// Access count covers owner + the real member only (2), not the guest.
|
||||
expect(screen.getByText(/Access \(2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -177,6 +177,11 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [removingId, setRemovingId] = useState(null)
|
||||
const [transferringId, setTransferringId] = useState(null)
|
||||
const [newGuestName, setNewGuestName] = useState('')
|
||||
const [addingGuest, setAddingGuest] = useState(false)
|
||||
const [renamingGuestId, setRenamingGuestId] = useState(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const toast = useToast()
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
@@ -227,6 +232,63 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransfer = async (newOwnerId, username) => {
|
||||
if (!confirm(t('members.confirmTransfer', { name: username }))) return
|
||||
setTransferringId(newOwnerId)
|
||||
try {
|
||||
await tripsApi.transferOwnership(tripId, newOwnerId)
|
||||
// The current user just dropped from owner to member — reload so the trip
|
||||
// state and permissions everywhere reflect the new ownership.
|
||||
onClose()
|
||||
window.location.reload()
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('members.transferError')))
|
||||
setTransferringId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddGuest = async () => {
|
||||
const name = newGuestName.trim()
|
||||
if (!name) return
|
||||
setAddingGuest(true)
|
||||
try {
|
||||
await tripsApi.createGuest(tripId, name)
|
||||
setNewGuestName('')
|
||||
await loadMembers()
|
||||
toast.success(t('members.guestAdded'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('members.guestAddError')))
|
||||
} finally {
|
||||
setAddingGuest(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameGuest = async (userId) => {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { setRenamingGuestId(null); return }
|
||||
try {
|
||||
await tripsApi.renameGuest(tripId, userId, name)
|
||||
setRenamingGuestId(null)
|
||||
await loadMembers()
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('members.guestRenameError')))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGuest = async (userId) => {
|
||||
if (!confirm(t('members.confirmRemoveGuest'))) return
|
||||
setRemovingId(userId)
|
||||
try {
|
||||
await tripsApi.deleteGuest(tripId, userId)
|
||||
await loadMembers()
|
||||
toast.success(t('members.guestRemoved'))
|
||||
} catch {
|
||||
toast.error(t('members.removeError'))
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (userId, isSelf) => {
|
||||
const msg = isSelf
|
||||
? t('members.confirmLeave')
|
||||
@@ -244,18 +306,20 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
}
|
||||
}
|
||||
|
||||
// Users not yet in the trip
|
||||
// Users not yet in the trip (guests are accountless and never live in the directory)
|
||||
const existingIds = new Set([
|
||||
data?.owner?.id,
|
||||
...(data?.members?.map(m => m.id) || []),
|
||||
])
|
||||
const availableUsers = allUsers.filter(u => !existingIds.has(u.id))
|
||||
const availableUsers = allUsers.filter(u => !existingIds.has(u.id) && !u.is_guest)
|
||||
|
||||
const isCurrentOwner = data?.owner?.id === user?.id
|
||||
const allMembers = data ? [
|
||||
// Real members (owner + accounts) and guests (#1362) are listed separately.
|
||||
const realMembers = data ? [
|
||||
{ ...data.owner, role: 'owner' },
|
||||
...data.members,
|
||||
...data.members.filter(m => !m.is_guest),
|
||||
] : []
|
||||
const guests = data ? data.members.filter(m => m.is_guest) : []
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
@@ -315,7 +379,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
|
||||
{t('members.access')} ({realMembers.length} {realMembers.length === 1 ? t('members.person') : t('members.persons')})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -327,7 +391,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{allMembers.map(member => {
|
||||
{realMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
@@ -347,6 +411,18 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentOwner && member.role !== 'owner' && (
|
||||
<button
|
||||
onClick={() => handleTransfer(member.id, member.username)}
|
||||
disabled={transferringId === member.id}
|
||||
title={t('members.makeOwner')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: transferringId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#d97706'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Crown size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button
|
||||
onClick={() => handleRemove(member.id, isSelf)}
|
||||
@@ -366,6 +442,97 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guests (#1362) — accountless participants, managed by the owner */}
|
||||
{(isCurrentOwner || guests.length > 0) && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<UserRound size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', margin: '0 0 10px', lineHeight: 1.5 }}>{t('members.guestsHint')}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{guests.map(g => (
|
||||
<div key={g.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={g.username} avatarUrl={null} />
|
||||
{renamingGuestId === g.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') setRenamingGuestId(null) }}
|
||||
onBlur={() => handleRenameGuest(g.id)}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{g.username}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<UserRound size={9} /> {t('members.guest')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCurrentOwner && renamingGuestId !== g.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setRenamingGuestId(g.id); setRenameValue(g.username) }}
|
||||
title={t('common.rename')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGuest(g.id)}
|
||||
disabled={removingId === g.id}
|
||||
title={t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === g.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrentOwner && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: guests.length > 0 ? 8 : 0 }}>
|
||||
<input
|
||||
value={newGuestName}
|
||||
onChange={e => setNewGuestName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddGuest}
|
||||
disabled={addingGuest || !newGuestName.trim()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: addingGuest || !newGuestName.trim() ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: addingGuest || !newGuestName.trim() ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={13} /> {addingGuest ? '…' : t('members.addGuest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { UserRound } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* Small "Guest" pill (#1362) shown next to a member's name in assignment pickers
|
||||
* so it's clear the person is an accountless guest. Purely presentational.
|
||||
*/
|
||||
export default function GuestBadge({ size = 'sm' }: { size?: 'sm' | 'xs' }) {
|
||||
const { t } = useTranslation()
|
||||
const fs = size === 'xs' ? 9 : 10
|
||||
return (
|
||||
<span
|
||||
title={t('members.guestsHint')}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0,
|
||||
fontSize: `calc(${fs}px * var(--fs-scale-caption, 1))`, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-tertiary)',
|
||||
padding: '1px 6px', borderRadius: 99,
|
||||
}}
|
||||
>
|
||||
<UserRound size={fs - 1} /> {t('members.guest')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export interface CachedTripMember extends TripMember {
|
||||
|
||||
// ── Queue + sync types ────────────────────────────────────────────────────────
|
||||
|
||||
export type MutationStatus = 'pending' | 'syncing' | 'failed';
|
||||
// 'conflict' is terminal-until-resolved: the server rejected the replay because
|
||||
// the entity changed underneath the offline edit (#1135 ask 3). It is surfaced
|
||||
// to the user for a keep-mine / keep-theirs decision rather than dropped.
|
||||
export type MutationStatus = 'pending' | 'syncing' | 'failed' | 'conflict';
|
||||
|
||||
export interface QueuedMutation {
|
||||
/** UUID — also used as X-Idempotency-Key sent to the server */
|
||||
@@ -33,6 +36,21 @@ export interface QueuedMutation {
|
||||
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
||||
*/
|
||||
tempEntityId?: number;
|
||||
/**
|
||||
* Optimistic-concurrency token: the entity's `updated_at` at the moment the
|
||||
* offline edit was made. Sent as `X-Base-Updated-At` on replay so the server
|
||||
* can reject the write (409) if someone else changed the entity in the
|
||||
* meantime. Absent for creates and for resources without a token.
|
||||
*/
|
||||
baseUpdatedAt?: string | null;
|
||||
/**
|
||||
* Set when the replay came back 409: the server's current version of the
|
||||
* entity, kept so the conflict resolver can show "theirs" beside "mine"
|
||||
* (which is reconstructed from `body`). Only present while status==='conflict'.
|
||||
*/
|
||||
conflictServer?: unknown;
|
||||
/** When the conflict was detected (for ordering / display). */
|
||||
conflictAt?: number;
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
@@ -348,7 +366,16 @@ export async function enforceBlobBudget(
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
/**
|
||||
* Delete one trip's cached READ data (eviction, per-trip opt-out). The offline
|
||||
* write queue is deliberately preserved except for already-dropped 'failed' rows:
|
||||
* a trip can be evicted for being stale, or turned off in the storage settings,
|
||||
* while it still holds unsynced offline edits (pending/syncing) or unresolved
|
||||
* conflicts — those must survive so the user's work is not silently lost (#1135).
|
||||
* The replay only needs the queued REST request, not the cached entities, and a
|
||||
* successful flush re-adds the canonical row. The full "Clear cache" wipe goes
|
||||
* through clearAll(), which intentionally drops everything.
|
||||
*/
|
||||
export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.transaction(
|
||||
'rw',
|
||||
@@ -376,7 +403,8 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.accommodations.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||
// Keep pending/syncing/conflict mutations — only purge dead 'failed' rows.
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
isEffectivelyOffline,
|
||||
isForcedOffline,
|
||||
setForcedOffline,
|
||||
onNetworkModeChange,
|
||||
} from '../sync/networkMode'
|
||||
|
||||
/**
|
||||
* React binding for the global network mode. Re-renders when the browser goes
|
||||
* online/offline or the user toggles force-offline.
|
||||
*
|
||||
* offline — the effective offline state (real disconnection OR forced)
|
||||
* forced — whether the user has the force-offline switch on
|
||||
* setForced — flip the force-offline switch
|
||||
*/
|
||||
export function useNetworkMode(): { offline: boolean; forced: boolean; setForced: (v: boolean) => void } {
|
||||
const offline = useSyncExternalStore(onNetworkModeChange, isEffectivelyOffline, () => true)
|
||||
const forced = useSyncExternalStore(onNetworkModeChange, isForcedOffline, () => false)
|
||||
return { offline, forced, setForced: setForcedOffline }
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
|
||||
uk: () => import('@trek/shared/i18n/uk'),
|
||||
gr: () => import('@trek/shared/i18n/gr'),
|
||||
sv: () => import('@trek/shared/i18n/sv'),
|
||||
vi: () => import('@trek/shared/i18n/vi'),
|
||||
}
|
||||
|
||||
// Re-export pure helpers that live in shared so downstream consumers can import them
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
LayoutGrid, List, Ticket, X, CalendarPlus,
|
||||
} from 'lucide-react'
|
||||
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -36,17 +37,33 @@ const GRADIENTS = [
|
||||
]
|
||||
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
|
||||
|
||||
// Day + short month for the boarding pass / cards, e.g. { d: '10', m: 'Sep' }.
|
||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
||||
// Day + short month for the boarding pass / cards, plus the year — but only
|
||||
// when it isn't the current year (this year's trips stay clutter-free), e.g.
|
||||
// { d: '10', m: 'Sep', y: '' } now vs { …, y: '2024' } for an older trip.
|
||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string; y: string } | null {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
||||
const otherYear = date.getUTCFullYear() !== new Date().getUTCFullYear()
|
||||
return {
|
||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||
y: otherYear ? date.toLocaleDateString(locale, { year: 'numeric', timeZone: 'UTC' }) : '',
|
||||
}
|
||||
}
|
||||
|
||||
// Localized date for the cards. The year is included only when it isn't the
|
||||
// current year, and order/punctuation follow the locale (EN "Sep 10, 2026",
|
||||
// DE "10. Sep 2026" — vs a plain "Sep 10" this year), never a hard-coded layout.
|
||||
function fullDate(dateStr: string | null | undefined, locale: string): string | null {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
if (isNaN(date.getTime())) return null
|
||||
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' }
|
||||
if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric'
|
||||
return date.toLocaleDateString(locale, opts)
|
||||
}
|
||||
|
||||
function buddyColor(seed: number): string {
|
||||
const pairs = [
|
||||
['#6366f1', '#8b5cf6'], ['#10b981', '#059669'], ['#f59e0b', '#d97706'],
|
||||
@@ -91,6 +108,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
|
||||
allSubOpen, setAllSubOpen,
|
||||
} = useDashboard()
|
||||
|
||||
// Per-device dashboard widget visibility (from the appearance config).
|
||||
@@ -149,11 +167,28 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<button
|
||||
className="tool-action"
|
||||
aria-label="Subscribe to all trips calendar"
|
||||
title="Subscribe to all trips"
|
||||
onClick={() => setAllSubOpen(true)}
|
||||
style={{ width: 38, height: 38, borderRadius: 11 }}
|
||||
>
|
||||
<CalendarPlus size={17} />
|
||||
</button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{allSubOpen && (
|
||||
<IcsSubscribeModal
|
||||
endpoint="/api/feed/user"
|
||||
title="Subscribe to all trips"
|
||||
description="One calendar feed for all your active trips, kept in sync automatically. Excludes archived trips and trips that ended more than 90 days ago."
|
||||
onClose={() => setAllSubOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||
<div className="trips-empty">
|
||||
@@ -303,10 +338,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
<div className="pass-cell dates-combined">
|
||||
<div className="pass-label">{t('dashboard.hero.tripDates')}</div>
|
||||
<div className="dates-row">
|
||||
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}</div></div>
|
||||
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}{start.y ? ` ${start.y}` : ''}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
<div className="date-arrow"><ArrowRight /></div>
|
||||
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}</div></div>
|
||||
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}{end.y ? ` ${end.y}` : ''}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,9 +546,9 @@ function TripCard({ trip, locale, onOpen, onEdit, onCopy, onArchive, onDelete }:
|
||||
<div className="trip-dates">
|
||||
{start && end ? (
|
||||
<>
|
||||
<span className="date-num">{start.m} {start.d}</span>
|
||||
<span className="date-num">{fullDate(trip.start_date, locale)}</span>
|
||||
<span className="date-arrow"><ArrowRight size={11} /></span>
|
||||
<span className="date-num">{end.m} {end.d}</span>
|
||||
<span className="date-num">{fullDate(trip.end_date, locale)}</span>
|
||||
</>
|
||||
) : <span>{t('dashboard.hero.noDates')}</span>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Search, ChevronRight, Loader2, AlertCircle, BookOpen, PanelLeft, X } from 'lucide-react'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useHelp } from './help/useHelp'
|
||||
|
||||
export default function HelpPage() {
|
||||
const { t } = useTranslation()
|
||||
const { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered } =
|
||||
useHelp()
|
||||
|
||||
const nav = (
|
||||
<nav className="flex flex-col gap-5">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('help.search')}
|
||||
className="w-full bg-surface-tertiary text-content rounded-lg pl-9 pr-3 py-2 text-[13px] outline-none border border-transparent focus:border-edge"
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((section) => (
|
||||
<div key={section.title}>
|
||||
{section.title && (
|
||||
<h3 className="text-[10px] font-semibold tracking-[0.1em] uppercase text-content-faint mb-1.5 px-2">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{section.pages.map((p) => {
|
||||
const active = p.slug === activeSlug
|
||||
return (
|
||||
<Link
|
||||
key={p.slug}
|
||||
to={`/help/${p.slug}`}
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-accent-subtle text-accent font-semibold'
|
||||
: 'text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{active && <ChevronRight size={13} className="shrink-0" />}
|
||||
<span className={active ? '' : 'pl-[18px]'}>{p.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!filtered.length && <p className="text-[12px] text-content-faint px-2">{t('help.noResults')}</p>}
|
||||
</nav>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell className="bg-surface-secondary" navOffset="var(--nav-h, 56px)">
|
||||
<div className="max-w-[1600px] mx-auto px-4 lg:px-10 py-6 flex gap-10">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:block w-[260px] shrink-0">
|
||||
<div className="sticky top-[calc(var(--nav-h,56px)+24px)] max-h-[calc(100vh-var(--nav-h,56px)-48px)] overflow-y-auto pr-1">
|
||||
<div className="flex items-center gap-2 mb-4 px-2">
|
||||
<BookOpen size={16} className="text-accent" />
|
||||
<span className="text-[14px] font-bold text-content">{t('help.title')}</span>
|
||||
</div>
|
||||
{nav}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 min-w-0" ref={contentRef}>
|
||||
{/* Mobile nav toggle */}
|
||||
<button
|
||||
onClick={() => setNavOpen(true)}
|
||||
className="lg:hidden inline-flex items-center gap-2 mb-4 px-3 py-2 rounded-lg bg-surface-card border border-edge text-[13px] font-medium text-content"
|
||||
>
|
||||
<PanelLeft size={15} /> {t('help.contents')}
|
||||
</button>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-24 text-content-faint">
|
||||
<Loader2 size={22} className="animate-spin" />
|
||||
</div>
|
||||
) : pageError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-24 text-center">
|
||||
<AlertCircle size={28} className="text-content-faint" />
|
||||
<p className="text-[14px] font-semibold text-content">{t('help.errorTitle')}</p>
|
||||
<p className="text-[13px] text-content-faint max-w-sm">{t('help.errorBody')}</p>
|
||||
</div>
|
||||
) : page ? (
|
||||
<article className="wiki-prose max-w-[1040px]">
|
||||
<WikiContent markdown={page.markdown} />
|
||||
</article>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar drawer */}
|
||||
{navOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-[120]" onClick={() => setNavOpen(false)}>
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[280px] bg-surface-card p-5 overflow-y-auto shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-[14px] font-bold text-content flex items-center gap-2">
|
||||
<BookOpen size={16} className="text-accent" /> {t('help.title')}
|
||||
</span>
|
||||
<button onClick={() => setNavOpen(false)} className="text-content-faint">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{nav}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Markdown renderer with TREK-styled elements and SPA-internal links. */
|
||||
function WikiContent({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-[26px] font-bold text-content mt-1 mb-4 leading-tight">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-[19px] font-bold text-content mt-8 mb-3 pb-1.5 border-b border-edge-secondary">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => <h3 className="text-[15.5px] font-semibold text-content mt-6 mb-2">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="text-[14px] font-semibold text-content mt-5 mb-2">{children}</h4>,
|
||||
p: ({ children }) => <p className="text-[14px] text-content-secondary leading-[1.7] my-3">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-[1.6]">{children}</li>,
|
||||
a: ({ href, children }) => {
|
||||
const url = href ?? ''
|
||||
if (url.startsWith('#')) return <a href={url} className="text-accent hover:underline">{children}</a>
|
||||
if (url.startsWith('/')) return <Link to={url} className="text-accent hover:underline font-medium">{children}</Link>
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline font-medium">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<img src={typeof src === 'string' ? src : ''} alt={alt} loading="lazy" className="rounded-lg border border-edge my-4 max-w-full" />
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = (className ?? '').includes('language-')
|
||||
if (isBlock) return <code className={className}>{children}</code>
|
||||
return <code className="bg-surface-tertiary text-content rounded px-1.5 py-0.5 text-[12.5px] font-mono">{children}</code>
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-tertiary text-content rounded-xl p-4 my-4 overflow-x-auto text-[12.5px] font-mono leading-relaxed border border-edge-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-3 border-accent bg-accent-subtle/40 rounded-r-lg px-4 py-1 my-4 text-content-secondary">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table className="w-full text-[13px] border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="text-left font-semibold text-content border border-edge-secondary px-3 py-2 bg-surface-tertiary">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => <td className="text-content-secondary border border-edge-secondary px-3 py-2">{children}</td>,
|
||||
hr: () => <hr className="my-6 border-edge-secondary" />,
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
@@ -1813,7 +1813,7 @@ describe('JourneyDetailPage', () => {
|
||||
expect(uploadBtn).toBeTruthy();
|
||||
|
||||
// Verify the hidden file input exists in the gallery view
|
||||
const fileInput = document.querySelector('input[type="file"][accept="image/*"]') as HTMLInputElement;
|
||||
const fileInput = document.querySelector('input[type="file"][accept="image/*,video/*"]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3314,7 +3314,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// Find the hidden file input in the gallery view
|
||||
const fileInput = document.querySelector('input[type="file"][accept="image/*"][multiple]') as HTMLInputElement;
|
||||
const fileInput = document.querySelector('input[type="file"][accept="image/*,video/*"][multiple]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
|
||||
// Simulate file selection
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function JourneyDetailPage() {
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -384,7 +384,7 @@ export default function JourneyDetailPage() {
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -408,7 +408,7 @@ export default function JourneyDetailPage() {
|
||||
journeyId={current.id}
|
||||
userId={useAuthStore.getState().user?.id || 0}
|
||||
trips={current.trips}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
</div>
|
||||
@@ -538,7 +538,7 @@ export default function JourneyDetailPage() {
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<PhotoLightbox
|
||||
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))}
|
||||
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.mediaType }))}
|
||||
startIndex={lightbox.index}
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import {
|
||||
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
|
||||
List, Grid, MapPin, Camera, BookOpen, Image, Clock, Play,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
@@ -123,7 +123,7 @@ export default function JourneyPublicPage() {
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
|
||||
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type }))
|
||||
|
||||
const isActive = activeEntryId === String(entry.id)
|
||||
return (
|
||||
@@ -296,10 +296,17 @@ export default function JourneyPublicPage() {
|
||||
{allPhotos.map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
{(photo as any).media_type === 'video' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
|
||||
<Play size={16} className="ml-0.5" fill="currentColor" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -513,6 +520,7 @@ export default function JourneyPublicPage() {
|
||||
id: String(p.id),
|
||||
src: photoUrl(p as any, token!, 'original'),
|
||||
caption: (p as any).caption ?? null,
|
||||
mediaType: (p as any).media_type,
|
||||
})),
|
||||
index: idx,
|
||||
})}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||
@@ -447,6 +447,17 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insecureCookie && (
|
||||
<div style={{ padding: '12px 14px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>{t('login.insecureCookie.title')}</div>
|
||||
<div style={{ lineHeight: 1.55 }}>{t('login.insecureCookie.body')}</div>
|
||||
<a href="https://github.com/mauriceboe/TREK/wiki/Troubleshooting" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-block', marginTop: 6, fontWeight: 600, color: '#b45309', textDecoration: 'underline' }}>
|
||||
{t('login.insecureCookie.link')} ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passwordChangeStep && (
|
||||
<>
|
||||
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
@@ -468,6 +468,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
onPlacesFilterChange={setMapPlacesFilter}
|
||||
pushUndo={pushUndo}
|
||||
@@ -610,7 +611,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<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} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useDashboard() {
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
||||
const [allSubOpen, setAllSubOpen] = useState<boolean>(false)
|
||||
const [loadError, setLoadError] = useState<boolean>(false)
|
||||
|
||||
const [stats, setStats] = useState<TravelStats | null>(null)
|
||||
@@ -192,6 +193,7 @@ export function useDashboard() {
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
allSubOpen, setAllSubOpen,
|
||||
// actions
|
||||
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { helpApi, type HelpNavSection, type HelpPageData } from '../../api/client'
|
||||
|
||||
/** State + data loading for the in-app help wiki (see PATTERN.md). */
|
||||
export function useHelp() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const [sections, setSections] = useState<HelpNavSection[]>([])
|
||||
const [page, setPage] = useState<HelpPageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pageError, setPageError] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
helpApi.index().then((d) => setSections(d.sections)).catch(() => setSections([]))
|
||||
}, [])
|
||||
|
||||
const homeSlug = sections[0]?.pages[0]?.slug ?? 'Home'
|
||||
const activeSlug = slug ?? homeSlug
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setLoading(true)
|
||||
setPageError(false)
|
||||
helpApi
|
||||
.page(activeSlug)
|
||||
.then((p) => {
|
||||
if (!alive) return
|
||||
setPage(p)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return
|
||||
setPageError(true)
|
||||
setLoading(false)
|
||||
})
|
||||
contentRef.current?.scrollTo?.({ top: 0 })
|
||||
window.scrollTo?.({ top: 0 })
|
||||
setNavOpen(false)
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [activeSlug])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return sections
|
||||
return sections
|
||||
.map((s) => ({ ...s, pages: s.pages.filter((p) => p.title.toLowerCase().includes(q)) }))
|
||||
.filter((s) => s.pages.length > 0)
|
||||
}, [sections, query])
|
||||
|
||||
return { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function useJourneyDetail() {
|
||||
const feedRef = useRef<HTMLDivElement>(null)
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null; mediaType?: string | null }[]; index: number } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useJourneyPublic() {
|
||||
const [error, setError] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
|
||||
@@ -41,6 +41,9 @@ export function useLogin() {
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
// Set when the server signals it just issued a Secure cookie over plain HTTP —
|
||||
// the browser drops it, so we explain the fix instead of a bare 401 later.
|
||||
const [insecureCookie, setInsecureCookie] = useState(false)
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||
@@ -225,6 +228,7 @@ export function useLogin() {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setInsecureCookie(false)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (passwordChangeStep) {
|
||||
@@ -260,6 +264,13 @@ export function useLogin() {
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
const result = await login(email, password, rememberMe)
|
||||
if ((result as { insecureCookie?: boolean }).insecureCookie) {
|
||||
// Credentials were correct, but the secure cookie won't survive plain
|
||||
// HTTP — proceeding would just dead-end on "Access token required".
|
||||
setInsecureCookie(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||
setMfaToken(result.mfa_token)
|
||||
setMfaStep(true)
|
||||
@@ -291,7 +302,7 @@ export function useLogin() {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
@@ -254,7 +255,7 @@ export function useTripPlanner() {
|
||||
if (tripId) {
|
||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
loadAccommodations()
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
||||
.then(rows => setTripMembers(rows))
|
||||
.catch(() => {})
|
||||
@@ -525,6 +526,32 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
|
||||
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
|
||||
if (!ids.length) return
|
||||
const state = useTripStore.getState()
|
||||
// Capture each place's prior category so undo can restore them per group.
|
||||
const captured = state.places.filter(p => ids.includes(p.id)).map(p => ({ id: p.id, prev: p.category_id ?? null }))
|
||||
try {
|
||||
await tripActions.updatePlacesMany(tripId, ids, { category_id: categoryId })
|
||||
toast.success(t('places.categoryChanged', { count: ids.length }))
|
||||
if (captured.length > 0) {
|
||||
pushUndo(t('undo.changeCategory'), async () => {
|
||||
// Group the captured ids by their prior category so each set is restored
|
||||
// in one call ('null' key = previously uncategorized). Map is shadowed by
|
||||
// the lucide icon import in this file, so use a plain object.
|
||||
const byPrev: Record<string, number[]> = {}
|
||||
for (const { id, prev } of captured) {
|
||||
const key = prev === null ? 'null' : String(prev)
|
||||
;(byPrev[key] ??= []).push(id)
|
||||
}
|
||||
for (const [key, group] of Object.entries(byPrev)) {
|
||||
await tripActions.updatePlacesMany(tripId, group, { category_id: key === 'null' ? null : Number(key) })
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
@@ -841,7 +868,7 @@ export function useTripPlanner() {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { packingApi } from '../api/client'
|
||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { PackingItem } from '../types'
|
||||
|
||||
@@ -20,7 +21,7 @@ export const packingRepo = {
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
const tempId = nextTempId()
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
@@ -48,7 +49,7 @@ export const packingRepo = {
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
const existing = await offlineDb.packingItems.get(id)
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||
await offlineDb.packingItems.put(optimistic)
|
||||
@@ -62,6 +63,7 @@ export const packingRepo = {
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
baseUpdatedAt: existing?.updated_at ?? null,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
return { item: optimistic }
|
||||
@@ -72,7 +74,7 @@ export const packingRepo = {
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
await offlineDb.packingItems.delete(id)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { placesApi } from '../api/client'
|
||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Place } from '../types'
|
||||
|
||||
@@ -20,7 +21,7 @@ export const placeRepo = {
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
const tempId = nextTempId()
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
@@ -47,7 +48,7 @@ export const placeRepo = {
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
const existing = await offlineDb.places.get(Number(id))
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||
await offlineDb.places.put(optimistic)
|
||||
@@ -61,6 +62,7 @@ export const placeRepo = {
|
||||
body: data,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
baseUpdatedAt: existing?.updated_at ?? null,
|
||||
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||
})
|
||||
return { place: optimistic }
|
||||
@@ -71,7 +73,7 @@ export const placeRepo = {
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
const isTemp = Number(id) < 0
|
||||
@@ -93,7 +95,7 @@ export const placeRepo = {
|
||||
},
|
||||
|
||||
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
if (isEffectivelyOffline()) {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
const mutId = generateUUID()
|
||||
@@ -115,4 +117,32 @@ export const placeRepo = {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
return result
|
||||
},
|
||||
|
||||
async updateMany(tripId: number | string, ids: number[], data: Record<string, unknown>): Promise<{ updated: number[]; count: number }> {
|
||||
if (isEffectivelyOffline()) {
|
||||
// Offline fans out one queued PUT per id (mirrors deleteMany's DELETE fan-out).
|
||||
for (const id of ids) {
|
||||
const existing = await offlineDb.places.get(id)
|
||||
if (existing) await offlineDb.places.put({ ...existing, ...(data as Partial<Place>) })
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
baseUpdatedAt: existing?.updated_at ?? null,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
}
|
||||
return { updated: ids, count: ids.length }
|
||||
}
|
||||
const result = await placesApi.bulkUpdate(tripId, ids, data as Parameters<typeof placesApi.bulkUpdate>[2])
|
||||
const cached = await offlineDb.places.bulkGet(ids)
|
||||
await offlineDb.places.bulkPut(cached.filter(Boolean).map(p => ({ ...(p as Place), ...(data as Partial<Place>) })))
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
/**
|
||||
* True when an error means the request never reached the server — a network-level
|
||||
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
|
||||
@@ -22,11 +24,11 @@ function isNetworkError(err: unknown): boolean {
|
||||
* connection (H2). Rather than surfacing that (which blanks the trip even
|
||||
* though a good cached copy exists), we fall back to the cache.
|
||||
*
|
||||
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
|
||||
* the probe is a coarse global flag, and a single failed health check would
|
||||
* otherwise force every read to the (possibly empty) cache even when the request
|
||||
* itself would succeed. The network-error catch below covers the captive-portal
|
||||
* case the probe was meant to.
|
||||
* We gate on the effective offline state (real `navigator.onLine` OR the user's
|
||||
* force-offline override), NOT the connectivity probe: the probe is a coarse
|
||||
* global flag, and a single failed health check would otherwise force every read
|
||||
* to the (possibly empty) cache even when the request itself would succeed. The
|
||||
* network-error catch below covers the captive-portal case the probe was meant to.
|
||||
*
|
||||
* A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it
|
||||
* is rethrown so callers can set error state, navigate away, etc.
|
||||
@@ -38,7 +40,7 @@ export async function onlineThenCache<T>(
|
||||
onlineFn: () => Promise<T>,
|
||||
cacheFn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!navigator.onLine) return cacheFn()
|
||||
if (isEffectivelyOffline()) return cacheFn()
|
||||
try {
|
||||
return await onlineFn()
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { captureVideoPoster, isVideoFile } from '../utils/videoPoster'
|
||||
|
||||
export interface Journey {
|
||||
id: number
|
||||
@@ -56,6 +57,9 @@ export interface JourneyPhoto {
|
||||
thumbnail_path?: string | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
// 'image' (default) or 'video' (#823)
|
||||
media_type?: string | null
|
||||
duration_ms?: number | null
|
||||
}
|
||||
|
||||
export interface GalleryPhoto {
|
||||
@@ -74,6 +78,9 @@ export interface GalleryPhoto {
|
||||
thumbnail_path?: string | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
// 'image' (default) or 'video' (#823)
|
||||
media_type?: string | null
|
||||
duration_ms?: number | null
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
@@ -270,8 +277,19 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
let data: { photos?: GalleryPhoto[] }
|
||||
if (isVideoFile(file)) {
|
||||
// Video: grab a poster frame + duration in the browser, then upload the
|
||||
// raw video + poster (#823). No server-side transcoding.
|
||||
const { poster, durationMs } = await captureVideoPoster(file)
|
||||
fd.append('video', file)
|
||||
if (poster) fd.append('poster', poster, 'poster.jpg')
|
||||
if (durationMs != null) fd.append('duration_ms', String(durationMs))
|
||||
data = await journeyApi.uploadGalleryVideo(journeyId, fd, opts)
|
||||
} else {
|
||||
fd.append('photos', file)
|
||||
data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
}
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('budgetSlice', () => {
|
||||
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
||||
)
|
||||
);
|
||||
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
|
||||
await expect(useTripStore.getState().addBudgetItem(1, { name: 'x' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
|
||||
|
||||
@@ -12,8 +12,8 @@ type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface BudgetSlice {
|
||||
loadBudgetItems: (tripId: number | string) => Promise<void>
|
||||
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
addBudgetItem: (tripId: number | string, data: BudgetCreateItemRequest) => Promise<BudgetItem>
|
||||
updateBudgetItem: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => Promise<BudgetItem>
|
||||
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
@@ -33,7 +33,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -43,7 +43,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// FE-STORE-PACKING-001 to FE-STORE-PACKING-002 (reorder, #969)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('packingSlice', () => {
|
||||
it('FE-STORE-PACKING-001: reorderPackingItems reorders optimistically and reindexes sort_order', async () => {
|
||||
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { packingItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/reorder', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items[0].id).toBe(2);
|
||||
expect(items[0].sort_order).toBe(0);
|
||||
expect(items[1].id).toBe(1);
|
||||
expect(items[1].sort_order).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-PACKING-002: reorderPackingItems rolls back to previous order on API error', async () => {
|
||||
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { packingItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/reorder', () =>
|
||||
HttpResponse.json({ error: 'error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
|
||||
// After failure the original order is restored
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items[0].id).toBe(1);
|
||||
expect(items[1].id).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { packingRepo } from '../../repo/packingRepo'
|
||||
import { packingApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
@@ -13,6 +14,12 @@ export interface PackingSlice {
|
||||
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
|
||||
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
|
||||
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
|
||||
reorderPackingItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
// Three-tier sharing (#858)
|
||||
setPackingItemSharing: (tripId: number | string, id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => Promise<void>
|
||||
clonePackingItem: (tripId: number | string, id: number) => Promise<void>
|
||||
addPackingContributor: (tripId: number | string, id: number) => Promise<void>
|
||||
removePackingContributor: (tripId: number | string, id: number, userId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
@@ -68,4 +75,62 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
notify(getApiErrorMessage(err, 'Error updating item'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
reorderPackingItems: async (tripId, orderedIds) => {
|
||||
const prev = get().packingItems
|
||||
// Optimistic reorder: rebuild the array in the requested order, reindexing
|
||||
// sort_order; any items not in orderedIds keep their place at the end.
|
||||
set(state => {
|
||||
const byId = new Map(state.packingItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds
|
||||
.map((id, idx): PackingItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
|
||||
.filter((i): i is PackingItem => i !== null)
|
||||
const remaining = state.packingItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { packingItems: [...reordered, ...remaining] }
|
||||
})
|
||||
try {
|
||||
await packingApi.reorder(tripId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
notify(getApiErrorMessage(err, 'Error reordering items'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
|
||||
setPackingItemSharing: async (tripId, id, visibility, recipientIds) => {
|
||||
try {
|
||||
const result = await packingApi.setSharing(tripId, id, { visibility, recipient_ids: recipientIds })
|
||||
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
|
||||
} catch (err: unknown) {
|
||||
notify(getApiErrorMessage(err, 'Error updating sharing'), 'error')
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
clonePackingItem: async (tripId, id) => {
|
||||
try {
|
||||
const result = await packingApi.clone(tripId, id)
|
||||
set(state => (state.packingItems.some(i => i.id === result.item.id) ? {} : { packingItems: [...state.packingItems, result.item] }))
|
||||
} catch (err: unknown) {
|
||||
notify(getApiErrorMessage(err, 'Error copying item'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
addPackingContributor: async (tripId, id) => {
|
||||
try {
|
||||
const result = await packingApi.addContributor(tripId, id)
|
||||
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
|
||||
} catch (err: unknown) {
|
||||
notify(getApiErrorMessage(err, 'Error joining item'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
removePackingContributor: async (tripId, id, userId) => {
|
||||
try {
|
||||
const result = await packingApi.removeContributor(tripId, id, userId)
|
||||
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
|
||||
} catch (err: unknown) {
|
||||
notify(getApiErrorMessage(err, 'Error leaving item'), 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PlacesSlice {
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
@@ -105,4 +106,33 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
|
||||
}
|
||||
},
|
||||
|
||||
updatePlacesMany: async (tripId, placeIds, patch) => {
|
||||
if (placeIds.length === 0) return
|
||||
try {
|
||||
await placeRepo.updateMany(tripId, placeIds, patch as Record<string, unknown>)
|
||||
const idSet = new Set(placeIds)
|
||||
set(state => {
|
||||
// Patch both the place pool and the embedded place on each day assignment
|
||||
// (preserving the assignment's own place_time/end_time) so itinerary cards
|
||||
// reflect the change immediately, like single updatePlace does.
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
|
||||
updatedAssignments[dayId] = items.map((a: Assignment) =>
|
||||
a.place?.id != null && idSet.has(a.place.id) ? { ...a, place: { ...a.place, ...patch } } : a
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.map(p => idSet.has(p.id) ? { ...p, ...patch } : p),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating places'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// FE-STORE-TODO-001 to FE-STORE-TODO-002 (reorder, #969)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildTodoItem } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('todoSlice', () => {
|
||||
it('FE-STORE-TODO-001: reorderTodoItems reorders optimistically and reindexes sort_order', async () => {
|
||||
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { todoItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/reorder', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items[0].id).toBe(2);
|
||||
expect(items[0].sort_order).toBe(0);
|
||||
expect(items[1].id).toBe(1);
|
||||
expect(items[1].sort_order).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-TODO-002: reorderTodoItems rolls back to previous order on API error', async () => {
|
||||
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { todoItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/reorder', () =>
|
||||
HttpResponse.json({ error: 'error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
|
||||
// After failure the original order is restored
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items[0].id).toBe(1);
|
||||
expect(items[1].id).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export interface TodoSlice {
|
||||
updateTodoItem: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => Promise<TodoItem>
|
||||
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
|
||||
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
|
||||
reorderTodoItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
@@ -69,4 +70,22 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
notify(getApiErrorMessage(err, 'Error updating todo'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
reorderTodoItems: async (tripId, orderedIds) => {
|
||||
const prev = get().todoItems
|
||||
set(state => {
|
||||
const byId = new Map(state.todoItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds
|
||||
.map((id, idx): TodoItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
|
||||
.filter((i): i is TodoItem => i !== null)
|
||||
const remaining = state.todoItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { todoItems: [...reordered, ...remaining] }
|
||||
})
|
||||
try {
|
||||
await todoApi.reorder(tripId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ todoItems: prev })
|
||||
notify(getApiErrorMessage(err, 'Error reordering todos'), 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { todoRepo } from '../repo/todoRepo'
|
||||
import { budgetRepo } from '../repo/budgetRepo'
|
||||
import { reservationRepo } from '../repo/reservationRepo'
|
||||
import { fileRepo } from '../repo/fileRepo'
|
||||
import { isEffectivelyOnline } from '../sync/networkMode'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDaysSlice } from './slices/daysSlice'
|
||||
@@ -128,10 +129,10 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
|
||||
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
|
||||
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
|
||||
navigator.onLine
|
||||
isEffectivelyOnline()
|
||||
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
||||
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
||||
navigator.onLine
|
||||
isEffectivelyOnline()
|
||||
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
|
||||
: offlineDb.categories.toArray().then(categories => ({ categories })),
|
||||
])
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isAuthed } from './authGate'
|
||||
import { isEffectivelyOffline } from './networkMode'
|
||||
import { getOfflinePrefs } from './offlinePrefs'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
@@ -62,6 +64,22 @@ function isRetryableStatus(status: number | undefined): boolean {
|
||||
return status === 401 || status === 408 || status === 425 || status === 429
|
||||
}
|
||||
|
||||
/** Pull the server's current entity out of a 409 response body ({ server: {...} }). */
|
||||
function extractConflictServer(err: unknown): unknown {
|
||||
const data = (err as { response?: { data?: unknown } })?.response?.data
|
||||
if (data && typeof data === 'object' && 'server' in data) {
|
||||
return (data as { server: unknown }).server
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Write a server entity into its Dexie table (used when "theirs" wins a conflict). */
|
||||
async function applyServerEntity(mutation: QueuedMutation, server: unknown): Promise<void> {
|
||||
if (!mutation.resource || !server || typeof server !== 'object' || !('id' in server)) return
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.put(server)
|
||||
}
|
||||
|
||||
export const mutationQueue = {
|
||||
/**
|
||||
* Add a mutation to the queue.
|
||||
@@ -89,12 +107,19 @@ export const mutationQueue = {
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine || !isAuthed()) return
|
||||
if (_flushing || isEffectivelyOffline() || !isAuthed()) return
|
||||
_flushing = true
|
||||
// tempId → realId learned during this flush, so a dependent edit/delete
|
||||
// queued against an offline-created entity (still holding the negative id)
|
||||
// can be rewritten to the server id before it is replayed.
|
||||
const idMap = new Map<number, number>()
|
||||
// resource:entityId → freshest updated_at applied during this flush. A second
|
||||
// queued edit of the same entity must send THIS token, not the stale one its
|
||||
// snapshot was loaded with, or it would 409 against our own first edit (#1135).
|
||||
const tokenMap = new Map<string, string>()
|
||||
// Set when a conflict auto-resolved as "mine wins": the mutation is re-queued
|
||||
// without its base token, so one more pass overwrites the server cleanly.
|
||||
let needsRetry = false
|
||||
try {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
@@ -128,11 +153,20 @@ export const mutationQueue = {
|
||||
}
|
||||
|
||||
try {
|
||||
// Send the optimistic-concurrency token when we have one so the server
|
||||
// can reject a stale overwrite (409). Absent header => unconditional
|
||||
// write (back-compat with servers / resources that don't check it).
|
||||
// A newer token learned earlier in THIS flush (an earlier edit of the
|
||||
// same entity) overrides the snapshot's stale base.
|
||||
const headers: Record<string, string> = { 'X-Idempotency-Key': mutation.id }
|
||||
const tokenKey = mutation.resource !== undefined && reqEntityId !== undefined ? `${mutation.resource}:${reqEntityId}` : undefined
|
||||
const baseToken = (tokenKey && tokenMap.get(tokenKey)) || mutation.baseUpdatedAt
|
||||
if (baseToken) headers['X-Base-Updated-At'] = baseToken
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: reqUrl,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
headers,
|
||||
})
|
||||
|
||||
// Apply canonical server response to Dexie
|
||||
@@ -161,6 +195,29 @@ export const mutationQueue = {
|
||||
})
|
||||
}
|
||||
await table.put(entity)
|
||||
// Advance the base-version token of any other queued edits to the
|
||||
// same entity to the value we just wrote. Without this, a second
|
||||
// offline edit of the same place/item still carries the pre-flush
|
||||
// token and would 409 against our OWN just-applied first edit —
|
||||
// self-conflicting and risking loss of the later edit (#1135).
|
||||
const newToken = (entity as { updated_at?: unknown }).updated_at
|
||||
if (typeof newToken === 'string') {
|
||||
// In-memory: consulted when the sibling is replayed later in this
|
||||
// same flush (its snapshot still holds the stale base).
|
||||
if (mutation.resource) tokenMap.set(`${mutation.resource}:${realId}`, newToken)
|
||||
// Durable: survives a flush boundary / reload if the sibling is
|
||||
// not reached this pass.
|
||||
await offlineDb.mutationQueue
|
||||
.where('tripId')
|
||||
.equals(mutation.tripId)
|
||||
.filter(m =>
|
||||
m.id !== mutation.id &&
|
||||
m.resource === mutation.resource &&
|
||||
m.entityId === realId &&
|
||||
(m.status === 'pending' || m.status === 'syncing'),
|
||||
)
|
||||
.modify(m => { m.baseUpdatedAt = newToken })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
|
||||
@@ -172,6 +229,37 @@ export const mutationQueue = {
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} catch (err: unknown) {
|
||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||
|
||||
// 409 = the entity changed on the server since this offline edit was
|
||||
// made. This is NOT a dropped change like other 4xx — resolve it per
|
||||
// the user's strategy instead of failing it. Deliberately scoped to
|
||||
// edits: an offline DELETE is "delete wins" by design (no CAS on the
|
||||
// delete path), so it never reaches here. See the wiki Offline doc.
|
||||
if (httpStatus === 409 && mutation.method !== 'DELETE') {
|
||||
const server = extractConflictServer(err)
|
||||
const strategy = getOfflinePrefs().conflictStrategy
|
||||
if (strategy === 'server') {
|
||||
// Theirs wins: adopt the server's version locally, drop our write.
|
||||
await applyServerEntity(mutation, server)
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} else if (strategy === 'mine') {
|
||||
// Mine wins: re-queue without the base token so the next pass
|
||||
// overwrites unconditionally.
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending', baseUpdatedAt: null, conflictServer: undefined,
|
||||
attempts: mutation.attempts + 1, lastError: null,
|
||||
})
|
||||
needsRetry = true
|
||||
} else {
|
||||
// Ask: park it as a conflict for the user to resolve.
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'conflict', conflictServer: server ?? null, conflictAt: Date.now(),
|
||||
attempts: mutation.attempts + 1, lastError: 'conflict',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const isTerminal =
|
||||
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
|
||||
if (isTerminal) {
|
||||
@@ -200,6 +288,12 @@ export const mutationQueue = {
|
||||
} finally {
|
||||
_flushing = false
|
||||
}
|
||||
// A "mine wins" auto-resolution dropped its base token; one more pass now
|
||||
// overwrites the server unconditionally. Bounded: the retried write carries
|
||||
// no token, so it cannot 409 for the same reason.
|
||||
if (needsRetry && !isEffectivelyOffline()) {
|
||||
await this.flush()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -237,6 +331,45 @@ export const mutationQueue = {
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Count unresolved sync conflicts (offline edits the server rejected as stale). */
|
||||
async conflictCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.equals('conflict')
|
||||
.count()
|
||||
},
|
||||
|
||||
/** All unresolved conflicts, newest first, optionally scoped to one trip. */
|
||||
async conflicts(tripId?: number): Promise<QueuedMutation[]> {
|
||||
const all = await offlineDb.mutationQueue.where('status').equals('conflict').toArray()
|
||||
const scoped = tripId === undefined ? all : all.filter(m => m.tripId === tripId)
|
||||
return scoped.sort((a, b) => (b.conflictAt ?? 0) - (a.conflictAt ?? 0))
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a conflict by keeping the local (offline) edit: re-queue it without
|
||||
* the base token so the next flush overwrites the server unconditionally.
|
||||
*/
|
||||
async resolveKeepMine(id: string): Promise<void> {
|
||||
const m = await offlineDb.mutationQueue.get(id)
|
||||
if (!m || m.status !== 'conflict') return
|
||||
await offlineDb.mutationQueue.update(id, {
|
||||
status: 'pending', baseUpdatedAt: null, conflictServer: undefined, conflictAt: undefined, lastError: null,
|
||||
})
|
||||
await this.flush()
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a conflict by keeping the server's version: adopt it into the local
|
||||
* cache and drop the queued write.
|
||||
*/
|
||||
async resolveKeepServer(id: string): Promise<void> {
|
||||
const m = await offlineDb.mutationQueue.get(id)
|
||||
if (!m || m.status !== 'conflict') return
|
||||
await applyServerEntity(m, m.conflictServer)
|
||||
await offlineDb.mutationQueue.delete(id)
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag and timestamp counters — useful in tests. */
|
||||
_resetFlushing(): void {
|
||||
_flushing = false
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Network mode — the single source of truth for whether the app should behave
|
||||
* as if it were offline right now.
|
||||
*
|
||||
* Two inputs combine here:
|
||||
* - the real browser state (`navigator.onLine`)
|
||||
* - a user-controlled "force offline" override (the Settings → Offline toggle)
|
||||
*
|
||||
* The repo layer, the mutation queue and the sync triggers all gate on
|
||||
* `isEffectivelyOffline()` instead of reading `navigator.onLine` directly, so a
|
||||
* forced-offline session routes every read to the Dexie cache and every write to
|
||||
* the mutation queue exactly as a genuine disconnection would. The override is
|
||||
* persisted so it survives a reload (a user who forced offline before boarding a
|
||||
* plane stays offline after the PWA is relaunched).
|
||||
*
|
||||
* Forcing offline does NOT pretend the network is gone for everything: it is the
|
||||
* caller's job (Settings → Offline) to pre-download first and only then flip the
|
||||
* switch. See tripSyncManager.prepareForOffline().
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'trek_forced_offline'
|
||||
|
||||
let _forced = readPersisted()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function readPersisted(): boolean {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function persist(v: boolean): void {
|
||||
try {
|
||||
if (v) localStorage.setItem(STORAGE_KEY, '1')
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
/* private mode / quota — the in-memory flag still governs this session */
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach(fn => {
|
||||
try { fn() } catch { /* a listener throwing must not break the others */ }
|
||||
})
|
||||
}
|
||||
|
||||
/** True when the user has manually forced the app into offline mode. */
|
||||
export function isForcedOffline(): boolean {
|
||||
return _forced
|
||||
}
|
||||
|
||||
/** Flip the manual force-offline override and notify subscribers. */
|
||||
export function setForcedOffline(v: boolean): void {
|
||||
if (_forced === v) return
|
||||
_forced = v
|
||||
persist(v)
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the app should treat itself as offline: either the browser is
|
||||
* genuinely offline OR the user forced offline mode. This is the flag the
|
||||
* offline read/write paths must gate on.
|
||||
*/
|
||||
export function isEffectivelyOffline(): boolean {
|
||||
return _forced || !navigator.onLine
|
||||
}
|
||||
|
||||
/** Convenience inverse of {@link isEffectivelyOffline}. */
|
||||
export function isEffectivelyOnline(): boolean {
|
||||
return !isEffectivelyOffline()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to network-mode changes (force-offline toggled, or the browser's own
|
||||
* online/offline events). Returns an unsubscribe function. Registers the global
|
||||
* browser listeners lazily on first subscription.
|
||||
*/
|
||||
export function onNetworkModeChange(fn: () => void): () => void {
|
||||
ensureBrowserListeners()
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
let _browserListenersBound = false
|
||||
function ensureBrowserListeners(): void {
|
||||
if (_browserListenersBound || typeof window === 'undefined') return
|
||||
_browserListenersBound = true
|
||||
window.addEventListener('online', notify)
|
||||
window.addEventListener('offline', notify)
|
||||
}
|
||||
|
||||
/** Reset state — test helper only. */
|
||||
export function _resetNetworkMode(): void {
|
||||
_forced = false
|
||||
listeners.clear()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Offline preferences — device-local choices about WHAT gets stored offline and
|
||||
* HOW sync conflicts are resolved (discussion #1135, asks 2 and 3).
|
||||
*
|
||||
* These live in localStorage rather than the server user-settings because they
|
||||
* are inherently per-device: how much storage a phone should spend on map tiles,
|
||||
* or which trips to keep on this particular device, has nothing to do with the
|
||||
* account and everything to do with the hardware in the user's hand.
|
||||
*
|
||||
* cacheTiles — global on/off for pre-downloading map tiles. Off keeps
|
||||
* the cache to trip data + documents only ("not the whole
|
||||
* world map"). See tripSyncManager / clearTileCache.
|
||||
* disabledTripIds — trips the user explicitly excluded from offline storage.
|
||||
* Everything else that is date-eligible is cached.
|
||||
* conflictStrategy — what to do when an offline edit collides with a newer
|
||||
* server change: 'ask' surfaces a per-conflict picker,
|
||||
* 'mine'/'server' resolve automatically.
|
||||
*/
|
||||
|
||||
export type ConflictStrategy = 'ask' | 'mine' | 'server'
|
||||
|
||||
export interface OfflinePrefs {
|
||||
cacheTiles: boolean
|
||||
disabledTripIds: number[]
|
||||
conflictStrategy: ConflictStrategy
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'trek_offline_prefs'
|
||||
|
||||
const DEFAULTS: OfflinePrefs = {
|
||||
cacheTiles: true,
|
||||
disabledTripIds: [],
|
||||
conflictStrategy: 'ask',
|
||||
}
|
||||
|
||||
let _prefs: OfflinePrefs = read()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function read(): OfflinePrefs {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
const parsed = JSON.parse(raw) as Partial<OfflinePrefs>
|
||||
return {
|
||||
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
|
||||
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
|
||||
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
function write(next: OfflinePrefs): void {
|
||||
_prefs = next
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* best-effort */ }
|
||||
listeners.forEach(fn => { try { fn() } catch { /* isolate listeners */ } })
|
||||
}
|
||||
|
||||
/** Current snapshot (a copy — callers must not mutate it in place). */
|
||||
export function getOfflinePrefs(): OfflinePrefs {
|
||||
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] }
|
||||
}
|
||||
|
||||
export function setCacheTiles(on: boolean): void {
|
||||
if (_prefs.cacheTiles === on) return
|
||||
write({ ..._prefs, cacheTiles: on })
|
||||
}
|
||||
|
||||
export function setConflictStrategy(strategy: ConflictStrategy): void {
|
||||
if (_prefs.conflictStrategy === strategy) return
|
||||
write({ ..._prefs, conflictStrategy: strategy })
|
||||
}
|
||||
|
||||
/** True when this trip should be cached offline (i.e. not explicitly disabled). */
|
||||
export function isTripOfflineEnabled(tripId: number): boolean {
|
||||
return !_prefs.disabledTripIds.includes(tripId)
|
||||
}
|
||||
|
||||
/** Turn offline storage for a single trip on or off. */
|
||||
export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
||||
const has = _prefs.disabledTripIds.includes(tripId)
|
||||
if (on && !has) return
|
||||
if (!on && has) return
|
||||
const disabledTripIds = on
|
||||
? _prefs.disabledTripIds.filter(id => id !== tripId)
|
||||
: [..._prefs.disabledTripIds, tripId]
|
||||
write({ ..._prefs, disabledTripIds })
|
||||
}
|
||||
|
||||
/** Subscribe to preference changes. Returns an unsubscribe function. */
|
||||
export function onOfflinePrefsChange(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** Reset to defaults — test helper only. */
|
||||
export function _resetOfflinePrefs(): void {
|
||||
_prefs = { ...DEFAULTS }
|
||||
listeners.clear()
|
||||
}
|
||||
@@ -14,12 +14,15 @@
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { isEffectivelyOnline, onNetworkModeChange } from './networkMode'
|
||||
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
|
||||
const PERIODIC_MS = 30_000
|
||||
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let _unsubscribeNetworkMode: (() => void) | null = null
|
||||
let _wasEffectivelyOnline = isEffectivelyOnline()
|
||||
let _registered = false
|
||||
|
||||
/** Pull the latest server state for every open trip into the Zustand store. */
|
||||
@@ -36,6 +39,11 @@ function rehydrateActiveTrips() {
|
||||
* edits made while we were offline appear without navigating away.
|
||||
*/
|
||||
function onOnline() {
|
||||
// A real browser reconnect must NOT override a user-forced offline session:
|
||||
// syncAll would re-seed Dexie from the server and wipe un-flushed optimistic
|
||||
// edits from the cache/UI. Stay put until the user lifts the switch (which
|
||||
// routes through onNetworkMode → here with the force flag already cleared).
|
||||
if (!isEffectivelyOnline()) return
|
||||
mutationQueue.flush()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
@@ -46,18 +54,30 @@ function onOnline() {
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
function onVisibility() {
|
||||
if (!document.hidden && navigator.onLine) {
|
||||
if (!document.hidden && isEffectivelyOnline()) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic heartbeat — drain any lingering pending mutations. */
|
||||
function onPeriodic() {
|
||||
if (navigator.onLine) {
|
||||
if (isEffectivelyOnline()) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The force-offline toggle (or a browser online/offline event) changed the
|
||||
* effective network mode. Coming back online — whether the network returned or
|
||||
* the user lifted the force-offline switch — behaves like a real reconnection:
|
||||
* flush queued writes, then re-seed and re-hydrate.
|
||||
*/
|
||||
function onNetworkMode() {
|
||||
const nowOnline = isEffectivelyOnline()
|
||||
if (nowOnline && !_wasEffectivelyOnline) onOnline()
|
||||
_wasEffectivelyOnline = nowOnline
|
||||
}
|
||||
|
||||
export function registerSyncTriggers(): void {
|
||||
if (_registered) return
|
||||
_registered = true
|
||||
@@ -73,6 +93,10 @@ export function registerSyncTriggers(): void {
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
// React to the force-offline toggle (and browser online/offline) so lifting
|
||||
// the switch immediately flushes + re-seeds like a real reconnection.
|
||||
_wasEffectivelyOnline = isEffectivelyOnline()
|
||||
_unsubscribeNetworkMode = onNetworkModeChange(onNetworkMode)
|
||||
_intervalId = setInterval(onPeriodic, PERIODIC_MS)
|
||||
}
|
||||
|
||||
@@ -84,6 +108,10 @@ export function unregisterSyncTriggers(): void {
|
||||
setRefetchCallback(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_unsubscribeNetworkMode) {
|
||||
_unsubscribeNetworkMode()
|
||||
_unsubscribeNetworkMode = null
|
||||
}
|
||||
if (_intervalId !== null) {
|
||||
clearInterval(_intervalId)
|
||||
_intervalId = null
|
||||
|
||||
@@ -143,11 +143,16 @@ export async function prefetchTiles(
|
||||
tileUrlTemplate: string,
|
||||
minZoom = 10,
|
||||
maxZoom = 16,
|
||||
awaitAll = false,
|
||||
): Promise<number> {
|
||||
if (!navigator.onLine) return 0
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
|
||||
|
||||
let fetched = 0
|
||||
// When awaitAll is set (the "prepare for offline" path), we wait for every tile
|
||||
// request to settle so the caller's progress bar only completes once the tiles
|
||||
// are actually downloaded into the SW cache — not merely dispatched.
|
||||
const inflight: Promise<unknown>[] = []
|
||||
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minX = lngToTileX(bbox.minLng, z)
|
||||
@@ -161,16 +166,32 @@ export async function prefetchTiles(
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const url = buildTileUrl(tileUrlTemplate, z, x, y)
|
||||
// Fire-and-forget: SW CacheFirst handler stores the response
|
||||
fetch(url, { mode: 'no-cors' }).catch(() => {})
|
||||
// SW CacheFirst handler stores the response. Fire-and-forget unless the
|
||||
// caller asked to await completion.
|
||||
const p = fetch(url, { mode: 'no-cors' }).catch(() => {})
|
||||
if (awaitAll) inflight.push(p)
|
||||
fetched++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (awaitAll && inflight.length) await Promise.allSettled(inflight)
|
||||
return fetched
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the pre-downloaded map-tile cache. Called when the user turns off
|
||||
* "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real
|
||||
* "whole world map" concern — is reclaimed immediately.
|
||||
*/
|
||||
export async function clearTileCache(): Promise<void> {
|
||||
try {
|
||||
if (typeof caches !== 'undefined') await caches.delete('map-tiles')
|
||||
} catch {
|
||||
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
|
||||
* Designed to be called fire-and-forget from tripSyncManager.
|
||||
@@ -179,6 +200,7 @@ export async function prefetchTilesForTrip(
|
||||
tripId: number,
|
||||
places: Place[],
|
||||
tileUrlTemplate?: string,
|
||||
awaitAll = false,
|
||||
): Promise<void> {
|
||||
const template = tileUrlTemplate || DEFAULT_TILE_URL
|
||||
const bbox = computeBbox(places)
|
||||
@@ -194,7 +216,7 @@ export async function prefetchTilesForTrip(
|
||||
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||
// these tiles under the inflated quota, we request persistent storage at app
|
||||
// init instead (sync/persistentStorage.ts).
|
||||
const fetched = await prefetchTiles(bbox, template)
|
||||
const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -71,6 +72,12 @@ function isPhoto(file: TripFile): boolean {
|
||||
return file.mime_type.startsWith('image/')
|
||||
}
|
||||
|
||||
// Videos can be hundreds of MB — never prefetch them into the bounded offline
|
||||
// blob cache, or a single clip would evict the trip's real documents (#823).
|
||||
function isVideo(file: TripFile): boolean {
|
||||
return file.mime_type.startsWith('video/')
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch bundle + write all entities for one trip into Dexie. */
|
||||
@@ -98,7 +105,7 @@ async function syncTrip(tripId: number): Promise<void> {
|
||||
|
||||
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
||||
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const nonPhotos = files.filter(f => f.url && !isPhoto(f))
|
||||
const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f))
|
||||
let cached = 0
|
||||
|
||||
for (const file of nonPhotos) {
|
||||
@@ -130,26 +137,46 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Progress callback payload for a {@link tripSyncManager.prepareForOffline} run. */
|
||||
export interface PrepareProgress {
|
||||
/** Current stage. 'done' fires once at the end. */
|
||||
phase: 'trips' | 'files' | 'tiles' | 'done'
|
||||
/** 1-based index of the trip currently processed in this phase. */
|
||||
current: number
|
||||
/** Total trips to process in this phase. */
|
||||
total: number
|
||||
/** Name of the trip currently processed (for the UI). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
let _syncing = false
|
||||
|
||||
/**
|
||||
* Decide which trips to cache and which to drop, honouring both the date rule
|
||||
* and the user's per-trip offline choices (#1135 ask 2). Returns the trips to
|
||||
* sync; clears Dexie for stale or user-disabled trips as a side effect.
|
||||
*/
|
||||
async function reconcileTrips(trips: Trip[]): Promise<Trip[]> {
|
||||
const stale = trips.filter(isStale)
|
||||
// Trips the user turned off explicitly are evicted regardless of date.
|
||||
const disabled = trips.filter(t => !isTripOfflineEnabled(t.id))
|
||||
await Promise.all([...stale, ...disabled].map(t => clearTripData(t.id).catch(console.error)))
|
||||
return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id))
|
||||
}
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale trips. Caches file blobs in the background.
|
||||
* No-ops when offline.
|
||||
* Evicts stale and user-disabled trips. Caches file blobs + map tiles in the
|
||||
* background. No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
const toSync = await reconcileTrips(trips)
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
@@ -163,19 +190,82 @@ export const tripSyncManager = {
|
||||
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
|
||||
|
||||
// Cache file blobs + map tiles in background (don't block syncAll)
|
||||
const cacheTiles = getOfflinePrefs().cacheTiles
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
for (const trip of toSync) {
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
if (cacheTiles) {
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* "Prepare for offline" (#1135 ask 1): a fully-awaited sync the user runs while
|
||||
* still online so everything they need is guaranteed on-device before they go
|
||||
* offline. Unlike syncAll, this AWAITS file-blob and map-tile downloads and
|
||||
* reports progress, so the UI can show a real completion state instead of
|
||||
* resolving the moment the requests are merely dispatched.
|
||||
*
|
||||
* Returns the number of trips prepared.
|
||||
*/
|
||||
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<number> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return 0
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
const toSync = await reconcileTrips(trips)
|
||||
const total = toSync.length
|
||||
|
||||
// 1) Trip bundles (structured data).
|
||||
let i = 0
|
||||
for (const trip of toSync) {
|
||||
onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title })
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
console.error(`[tripSync] prepare failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Global user data (tags + categories) — awaited here.
|
||||
await Promise.all([
|
||||
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}),
|
||||
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}),
|
||||
])
|
||||
|
||||
// 2) File blobs — awaited so "prepared" really means downloaded.
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
onProgress?.({ phase: 'files', current: ++i, total, label: trip.title })
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
await cacheFilesForTrip(files).catch(console.error)
|
||||
}
|
||||
|
||||
// 3) Map tiles — awaited, and only when the user opted to store them.
|
||||
if (getOfflinePrefs().cacheTiles) {
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'done', current: total, total })
|
||||
return total
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset syncing flag — useful in tests. */
|
||||
_resetSyncing(): void {
|
||||
_syncing = false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getCachedBlob } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
// MIME types safe to open inline (will not execute script in any browser).
|
||||
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
||||
@@ -51,7 +52,7 @@ function isIosStandalone(): boolean {
|
||||
*/
|
||||
async function getFileBlob(url: string): Promise<Blob> {
|
||||
assertRelativeUrl(url)
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
if (typeof navigator !== 'undefined' && isEffectivelyOffline()) {
|
||||
const cached = await getCachedBlob(url)
|
||||
if (cached) return cached
|
||||
throw new Error('File not available offline')
|
||||
|
||||
@@ -93,11 +93,15 @@ export function formatMoney(
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
timeZone: timeZone || 'UTC',
|
||||
}
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
|
||||
// Show the year only when it isn't the current year, so this year's dates stay
|
||||
// compact while older/future ones are unambiguous.
|
||||
if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric'
|
||||
return date.toLocaleDateString(locale, opts)
|
||||
}
|
||||
|
||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Capture a poster frame and duration from a video file entirely in the browser
|
||||
* (#823). This avoids any server-side transcoding: the picked video is decoded by
|
||||
* the browser, a frame is drawn to a canvas and exported as a JPEG that is
|
||||
* uploaded alongside the video and stored as its thumbnail.
|
||||
*
|
||||
* Resolves with a null poster (and best-effort duration) if anything fails — the
|
||||
* caller still uploads the video; the gallery just shows a placeholder tile.
|
||||
*/
|
||||
export async function captureVideoPoster(file: File): Promise<{ poster: Blob | null; durationMs: number | null }> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof document === 'undefined') { resolve({ poster: null, durationMs: null }); return }
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.src = url
|
||||
|
||||
let settled = false
|
||||
const finish = (poster: Blob | null, durationMs: number | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
URL.revokeObjectURL(url)
|
||||
resolve({ poster, durationMs })
|
||||
}
|
||||
// Don't hang forever on a codec the browser can't decode.
|
||||
const timer = setTimeout(() => finish(null, null), 10_000)
|
||||
|
||||
video.onerror = () => { clearTimeout(timer); finish(null, null) }
|
||||
video.onloadedmetadata = () => {
|
||||
const durationMs = Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : null
|
||||
// Seek slightly in to dodge an all-black first frame.
|
||||
const target = Math.min(0.1, (video.duration || 1) / 2)
|
||||
video.onseeked = () => {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth || 640
|
||||
canvas.height = video.videoHeight || 360
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return finish(null, durationMs)
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
canvas.toBlob((blob) => finish(blob, durationMs), 'image/jpeg', 0.8)
|
||||
} catch {
|
||||
finish(null, durationMs)
|
||||
}
|
||||
}
|
||||
try { video.currentTime = target } catch { clearTimeout(timer); finish(null, durationMs) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** True for a File the user picked that should go through the video upload path. */
|
||||
export function isVideoFile(file: File): boolean {
|
||||
return typeof file.type === 'string' && file.type.startsWith('video/')
|
||||
}
|
||||
@@ -319,6 +319,22 @@ describe('offlineDb — clearTripData', () => {
|
||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves unsynced (pending/conflict) writes but drops dead failed ones (#1135)', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await offlineDb.mutationQueue.bulkPut([
|
||||
{ id: 'p1', tripId: 1, method: 'PUT', url: '/trips/1/places/10', body: { name: 'X' }, createdAt: 1, status: 'pending', attempts: 0, lastError: null, resource: 'places', entityId: 10 },
|
||||
{ id: 'c1', tripId: 1, method: 'PUT', url: '/trips/1/places/11', body: { name: 'Y' }, createdAt: 2, status: 'conflict', attempts: 1, lastError: 'conflict', resource: 'places', entityId: 11 },
|
||||
{ id: 'f1', tripId: 1, method: 'PUT', url: '/trips/1/places/12', body: { name: 'Z' }, createdAt: 3, status: 'failed', attempts: 1, lastError: 'boom', resource: 'places', entityId: 12 },
|
||||
]);
|
||||
|
||||
await clearTripData(1);
|
||||
|
||||
// The trip's cached read data is gone, but the unsynced work survives.
|
||||
expect(await offlineDb.mutationQueue.get('p1')).toBeDefined();
|
||||
expect(await offlineDb.mutationQueue.get('c1')).toBeDefined();
|
||||
expect(await offlineDb.mutationQueue.get('f1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearAll', () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
|
||||
describe('SUPPORTED_LANGUAGES', () => {
|
||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||
expect(SUPPORTED_LANGUAGES).toHaveLength(21)
|
||||
expect(SUPPORTED_LANGUAGES).toHaveLength(22)
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
|
||||
@@ -99,6 +99,7 @@ describe('SUPPORTED_LANGUAGES', () => {
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'sv', label: 'Svenska' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'vi', label: 'Tiếng Việt' }))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -91,6 +91,35 @@ describe('placesSlice', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlacesMany', () => {
|
||||
it('FE-PLACES-008: applies the patch to every listed place and cascades to assignments', async () => {
|
||||
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
|
||||
const b = buildPlace({ id: 20, trip_id: 1, category_id: 1 });
|
||||
const c = buildPlace({ id: 30, trip_id: 1, category_id: 9 });
|
||||
const assignment = buildAssignment({ id: 100, day_id: 1, place: a });
|
||||
seedStore(useTripStore, { places: [a, b, c], assignments: { '1': [assignment] } });
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places/bulk-update', () => HttpResponse.json({ updated: [10, 20], count: 2 })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().updatePlacesMany(1, [10, 20], { category_id: 5 });
|
||||
|
||||
const places = useTripStore.getState().places;
|
||||
expect(places.find(p => p.id === 10)?.category_id).toBe(5);
|
||||
expect(places.find(p => p.id === 20)?.category_id).toBe(5);
|
||||
expect(places.find(p => p.id === 30)?.category_id).toBe(9); // untouched
|
||||
expect(useTripStore.getState().assignments['1'][0].place.category_id).toBe(5); // cascaded
|
||||
});
|
||||
|
||||
it('FE-PLACES-009: no-ops on an empty id list without calling the API', async () => {
|
||||
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
|
||||
seedStore(useTripStore, { places: [a] });
|
||||
await useTripStore.getState().updatePlacesMany(1, [], { category_id: 5 });
|
||||
expect(useTripStore.getState().places[0].category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePlace', () => {
|
||||
it('FE-PLACES-005: deletePlace removes place from places array', async () => {
|
||||
const place1 = buildPlace({ id: 10, trip_id: 1 });
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* mutationQueue conflict tests (#1135) — 409 handling + resolution.
|
||||
*
|
||||
* Covers: X-Base-Updated-At header, 'ask' parks a conflict, keep-mine re-sends
|
||||
* unconditionally, keep-theirs adopts the server entity, and the 'mine'/'server'
|
||||
* auto-strategies.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import 'fake-indexeddb/auto'
|
||||
import { server } from '../../helpers/msw/server'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setAuthed } from '../../../src/sync/authGate'
|
||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue'
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb'
|
||||
import { _resetNetworkMode } from '../../../src/sync/networkMode'
|
||||
import { _resetOfflinePrefs, setConflictStrategy } from '../../../src/sync/offlinePrefs'
|
||||
import { buildPlace } from '../../helpers/factories'
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
mutationQueue._resetFlushing()
|
||||
_resetNetworkMode()
|
||||
_resetOfflinePrefs()
|
||||
setAuthed(true)
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setAuthed(false)
|
||||
})
|
||||
|
||||
const BASE = '2026-01-01 00:00:00'
|
||||
|
||||
function enqueueConflictingPut(id: string, baseUpdatedAt: string | null = BASE) {
|
||||
return mutationQueue.enqueue({
|
||||
id, tripId: 1, method: 'PUT', url: '/trips/1/places/42',
|
||||
body: { name: 'Mine' }, resource: 'places', entityId: 42, baseUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Server that 409s when the base token is sent, and 200s once it isn't. */
|
||||
function conflictThenAcceptHandler(serverName = 'Theirs') {
|
||||
server.use(
|
||||
http.put('/api/trips/1/places/42', ({ request }) => {
|
||||
if (request.headers.get('X-Base-Updated-At')) {
|
||||
return HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: serverName }) }, { status: 409 })
|
||||
}
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Mine' }) })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('mutationQueue — base-version header', () => {
|
||||
it('sends X-Base-Updated-At when the mutation carries a base version', async () => {
|
||||
let captured: string | null = null
|
||||
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
|
||||
captured = request.headers.get('X-Base-Updated-At')
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
|
||||
}))
|
||||
await enqueueConflictingPut(generateUUID())
|
||||
await mutationQueue.flush()
|
||||
expect(captured).toBe(BASE)
|
||||
})
|
||||
|
||||
it('omits the header when there is no base version', async () => {
|
||||
let hadHeader = true
|
||||
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
|
||||
hadHeader = request.headers.has('X-Base-Updated-At')
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
|
||||
}))
|
||||
await enqueueConflictingPut(generateUUID(), null)
|
||||
await mutationQueue.flush()
|
||||
expect(hadHeader).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — 409 with strategy "ask"', () => {
|
||||
it('parks the mutation as a conflict carrying the server version', async () => {
|
||||
const id = generateUUID()
|
||||
server.use(http.put('/api/trips/1/places/42', () =>
|
||||
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
|
||||
await enqueueConflictingPut(id)
|
||||
|
||||
await mutationQueue.flush()
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id)
|
||||
expect(m!.status).toBe('conflict')
|
||||
expect((m!.conflictServer as { name: string }).name).toBe('Theirs')
|
||||
expect(await mutationQueue.conflictCount()).toBe(1)
|
||||
expect(await mutationQueue.failedCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not count a conflict as pending and is skipped by later flushes', async () => {
|
||||
const id = generateUUID()
|
||||
server.use(http.put('/api/trips/1/places/42', () =>
|
||||
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42 }) }, { status: 409 })))
|
||||
await enqueueConflictingPut(id)
|
||||
await mutationQueue.flush()
|
||||
expect(await mutationQueue.pendingCount()).toBe(0)
|
||||
// A second flush must not touch the parked conflict.
|
||||
await mutationQueue.flush()
|
||||
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — conflict resolution', () => {
|
||||
it('keep-mine re-sends without the base token and clears the conflict', async () => {
|
||||
const id = generateUUID()
|
||||
conflictThenAcceptHandler()
|
||||
await enqueueConflictingPut(id)
|
||||
await mutationQueue.flush()
|
||||
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
|
||||
|
||||
await mutationQueue.resolveKeepMine(id)
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
|
||||
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
|
||||
expect(await mutationQueue.conflictCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keep-theirs adopts the server entity and drops the queued write', async () => {
|
||||
const id = generateUUID()
|
||||
server.use(http.put('/api/trips/1/places/42', () =>
|
||||
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
|
||||
await enqueueConflictingPut(id)
|
||||
await mutationQueue.flush()
|
||||
|
||||
await mutationQueue.resolveKeepServer(id)
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
|
||||
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — chained edits to the same entity', () => {
|
||||
it('do NOT self-conflict: the new token is propagated to the next queued edit (#1135)', async () => {
|
||||
// A server that does real compare-and-swap on X-Base-Updated-At and bumps
|
||||
// the token on each accepted write.
|
||||
let token = 'T0'
|
||||
let serverPlace = { ...buildPlace({ trip_id: 1, id: 42, name: 'A' }), notes: 'orig', updated_at: token } as Record<string, unknown>
|
||||
server.use(http.put('/api/trips/1/places/42', async ({ request }) => {
|
||||
const base = request.headers.get('X-Base-Updated-At')
|
||||
if (base !== token) return HttpResponse.json({ error: 'conflict', server: serverPlace }, { status: 409 })
|
||||
const body = await request.json() as Record<string, unknown>
|
||||
token = token === 'T0' ? 'T1' : 'T2'
|
||||
serverPlace = { ...serverPlace, ...body, updated_at: token }
|
||||
return HttpResponse.json({ place: serverPlace })
|
||||
}))
|
||||
|
||||
await offlineDb.places.put({ ...(serverPlace as object) } as never)
|
||||
// Two offline edits to different fields of place 42, both based on T0.
|
||||
await mutationQueue.enqueue({ id: 'm1', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'B' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
|
||||
await mutationQueue.enqueue({ id: 'm2', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { notes: 'edited' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
|
||||
|
||||
await mutationQueue.flush()
|
||||
|
||||
expect(await mutationQueue.conflictCount()).toBe(0)
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
const final = await offlineDb.places.get(42) as unknown as { name: string; notes: string }
|
||||
expect(final.name).toBe('B')
|
||||
expect(final.notes).toBe('edited')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — auto strategies', () => {
|
||||
it('"server" adopts the server version automatically', async () => {
|
||||
setConflictStrategy('server')
|
||||
const id = generateUUID()
|
||||
server.use(http.put('/api/trips/1/places/42', () =>
|
||||
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
|
||||
await enqueueConflictingPut(id)
|
||||
|
||||
await mutationQueue.flush()
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
|
||||
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
|
||||
expect(await mutationQueue.conflictCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('"mine" re-sends unconditionally and wins', async () => {
|
||||
setConflictStrategy('mine')
|
||||
const id = generateUUID()
|
||||
conflictThenAcceptHandler()
|
||||
await enqueueConflictingPut(id)
|
||||
|
||||
await mutationQueue.flush()
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
|
||||
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
|
||||
expect(await mutationQueue.conflictCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* networkMode unit tests — the force-offline override + effective offline state.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
isEffectivelyOffline, isEffectivelyOnline, isForcedOffline,
|
||||
setForcedOffline, onNetworkModeChange, _resetNetworkMode,
|
||||
} from '../../../src/sync/networkMode'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetNetworkMode()
|
||||
try { localStorage.removeItem('trek_forced_offline') } catch { /* ignore */ }
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
describe('networkMode', () => {
|
||||
it('is online by default', () => {
|
||||
expect(isForcedOffline()).toBe(false)
|
||||
expect(isEffectivelyOffline()).toBe(false)
|
||||
expect(isEffectivelyOnline()).toBe(true)
|
||||
})
|
||||
|
||||
it('forced offline overrides a real online connection', () => {
|
||||
setForcedOffline(true)
|
||||
expect(isForcedOffline()).toBe(true)
|
||||
expect(isEffectivelyOffline()).toBe(true)
|
||||
expect(isEffectivelyOnline()).toBe(false)
|
||||
})
|
||||
|
||||
it('reports offline when the browser is offline even without the force flag', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true })
|
||||
expect(isForcedOffline()).toBe(false)
|
||||
expect(isEffectivelyOffline()).toBe(true)
|
||||
})
|
||||
|
||||
it('notifies subscribers on change, ignores no-op sets, and stops after unsubscribe', () => {
|
||||
let count = 0
|
||||
const unsub = onNetworkModeChange(() => { count++ })
|
||||
setForcedOffline(true)
|
||||
expect(count).toBe(1)
|
||||
setForcedOffline(true) // same value → no notification
|
||||
expect(count).toBe(1)
|
||||
setForcedOffline(false)
|
||||
expect(count).toBe(2)
|
||||
unsub()
|
||||
setForcedOffline(true)
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
it('persists the forced flag to localStorage', () => {
|
||||
setForcedOffline(true)
|
||||
expect(localStorage.getItem('trek_forced_offline')).toBe('1')
|
||||
setForcedOffline(false)
|
||||
expect(localStorage.getItem('trek_forced_offline')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* offlinePrefs unit tests — device-local "what to store offline" + conflict strategy.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs,
|
||||
} from '../../../src/sync/offlinePrefs'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOfflinePrefs()
|
||||
try { localStorage.removeItem('trek_offline_prefs') } catch { /* ignore */ }
|
||||
})
|
||||
|
||||
describe('offlinePrefs', () => {
|
||||
it('defaults to tiles on, no disabled trips, ask strategy', () => {
|
||||
const p = getOfflinePrefs()
|
||||
expect(p.cacheTiles).toBe(true)
|
||||
expect(p.disabledTripIds).toEqual([])
|
||||
expect(p.conflictStrategy).toBe('ask')
|
||||
expect(isTripOfflineEnabled(5)).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles tile caching and persists it', () => {
|
||||
setCacheTiles(false)
|
||||
expect(getOfflinePrefs().cacheTiles).toBe(false)
|
||||
expect(JSON.parse(localStorage.getItem('trek_offline_prefs')!).cacheTiles).toBe(false)
|
||||
})
|
||||
|
||||
it('disables and re-enables a single trip', () => {
|
||||
setTripOfflineEnabled(7, false)
|
||||
expect(isTripOfflineEnabled(7)).toBe(false)
|
||||
expect(getOfflinePrefs().disabledTripIds).toContain(7)
|
||||
|
||||
setTripOfflineEnabled(7, true)
|
||||
expect(isTripOfflineEnabled(7)).toBe(true)
|
||||
expect(getOfflinePrefs().disabledTripIds).not.toContain(7)
|
||||
})
|
||||
|
||||
it('does not duplicate a trip id when disabled twice', () => {
|
||||
setTripOfflineEnabled(3, false)
|
||||
setTripOfflineEnabled(3, false)
|
||||
expect(getOfflinePrefs().disabledTripIds.filter(id => id === 3)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sets the conflict strategy', () => {
|
||||
setConflictStrategy('mine')
|
||||
expect(getOfflinePrefs().conflictStrategy).toBe('mine')
|
||||
})
|
||||
|
||||
it('notifies subscribers and stops after unsubscribe', () => {
|
||||
let n = 0
|
||||
const unsub = onOfflinePrefsChange(() => { n++ })
|
||||
setCacheTiles(false)
|
||||
expect(n).toBe(1)
|
||||
unsub()
|
||||
setCacheTiles(true)
|
||||
expect(n).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* videoPoster unit tests (#823). The poster-capture path needs a real <video>
|
||||
* decoder + canvas, which jsdom does not provide, so we cover the pure file-type
|
||||
* gate here; poster capture is exercised manually / in the browser.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isVideoFile } from '../../../src/utils/videoPoster'
|
||||
|
||||
describe('isVideoFile', () => {
|
||||
it('is true for a video MIME type', () => {
|
||||
expect(isVideoFile(new File([], 'clip.mp4', { type: 'video/mp4' }))).toBe(true)
|
||||
expect(isVideoFile(new File([], 'clip.webm', { type: 'video/webm' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for images and other files', () => {
|
||||
expect(isVideoFile(new File([], 'photo.jpg', { type: 'image/jpeg' }))).toBe(false)
|
||||
expect(isVideoFile(new File([], 'doc.pdf', { type: 'application/pdf' }))).toBe(false)
|
||||
expect(isVideoFile(new File([], 'noext', { type: '' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
Generated
+49
@@ -40,6 +40,7 @@
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
@@ -9439,6 +9440,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||
@@ -9564,6 +9576,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/custom-event-polyfill": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -13484,6 +13502,12 @@
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/loadjs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -15769,6 +15793,19 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.8.4",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz",
|
||||
"integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.45.1",
|
||||
"custom-event-polyfill": "^1.0.7",
|
||||
"loadjs": "^4.3.0",
|
||||
"rangetouch": "^2.0.1",
|
||||
"url-polyfill": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||
@@ -16562,6 +16599,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
@@ -19703,6 +19746,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -3071,6 +3071,115 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL');
|
||||
},
|
||||
// Optimistic-concurrency token for offline conflict detection (#1135).
|
||||
// packing_items had only created_at, so an offline edit could not be checked
|
||||
// against a concurrent server change. SQLite forbids a non-constant DEFAULT on
|
||||
// ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows
|
||||
// set it explicitly (packingService). Additive: a request without the
|
||||
// X-Base-Updated-At header keeps the old last-write-wins behaviour.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE packing_items ADD COLUMN updated_at DATETIME');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL');
|
||||
},
|
||||
// Video support (#823): the trek_photos registry held only images. media_type
|
||||
// discriminates image vs video so the gallery, lightbox and provider proxy can
|
||||
// branch; duration_ms is optional metadata for the player. Additive — existing
|
||||
// rows default to 'image'.
|
||||
() => {
|
||||
for (const stmt of [
|
||||
"ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'",
|
||||
'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER',
|
||||
]) {
|
||||
try {
|
||||
db.exec(stmt);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Dedicated booking URL (#935) — users previously stuffed links into notes.
|
||||
// Additive nullable TEXT; existing rows default to NULL.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE reservations ADD COLUMN url TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Private packing items (#858): an item can be hidden from other trip members.
|
||||
// is_private toggles the visibility; owner_id records who it belongs to so the
|
||||
// listing can show it only to them. owner_id is NULL on legacy rows (shared).
|
||||
() => {
|
||||
for (const stmt of [
|
||||
'ALTER TABLE packing_items ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE packing_items ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL',
|
||||
]) {
|
||||
try {
|
||||
db.exec(stmt);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Guest members (#1362): people added to a trip without an account. A guest is a
|
||||
// users row flagged is_guest=1 (no usable credentials) joined into trip_members,
|
||||
// so it's assignable everywhere a member is — but must never authenticate or show
|
||||
// up in the global user directory. The flag is the discriminator for those guards.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0,
|
||||
// every existing item — non-breaking), Personal (is_private=1, owner only) or
|
||||
// Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer".
|
||||
// Contributors are extra people who said "I can bring that too" on a Common item
|
||||
// (status 'pending' until the owner accepts).
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS packing_item_recipients (
|
||||
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (item_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id);
|
||||
CREATE TABLE IF NOT EXISTS packing_item_contributors (
|
||||
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'accepted',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (item_id, user_id)
|
||||
);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user