diff --git a/.dockerignore b/.dockerignore index 05df9e931..5b1aea136 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,7 +30,6 @@ Thumbs.db sonar-project.properties server/tests/ server/vitest.config.ts -server/reset-admin.js **/*.test.ts **/*.spec.ts wiki/ diff --git a/.github/workflows/publish-plugin-sdk.yml b/.github/workflows/publish-plugin-sdk.yml new file mode 100644 index 000000000..7be1cb01c --- /dev/null +++ b/.github/workflows/publish-plugin-sdk.yml @@ -0,0 +1,32 @@ +name: Publish plugin-sdk to npm + +# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed. +# One-time setup: add an npm automation token as the repo secret NPM_TOKEN. +# The package's prepublishOnly hook builds + tests before publishing. + +on: + push: + tags: + - 'plugin-sdk-v*' + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin-sdk + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # 22 matches the TREK server runtime and has node:sqlite, which the + # dev-server tests exercise. + node-version: 22 + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index b1e4bdf26..fef272b87 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,6 @@ coverage test-data .run -.full-review \ No newline at end of file +.full-review +# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/) +server/assets/wiki/ diff --git a/Dockerfile b/Dockerfile index e6e8f1a2c..c05da667a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,23 +46,11 @@ COPY package.json package-lock.json ./ COPY shared/package.json ./shared/ COPY server/package.json ./server/ -# better-sqlite3 native addon requires build tools (purged after compile). -# kitinerary-extractor for booking-confirmation import: -# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck) -# arm64 — apt package (KDE publishes no arm64 static binary) RUN apt-get update && \ - apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \ + apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ + libkitinerary-bin && \ npm ci --workspace=server --omit=dev && \ - ARCH=$(dpkg --print-architecture) && \ - if [ "$ARCH" = "amd64" ]; then \ - wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \ - echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \ - tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \ - rm /tmp/ki.tgz; \ - else \ - apt-get install -y --no-install-recommends libkitinerary-bin && \ - ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ - fi && \ + ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ apt-get purge -y python3 build-essential && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx @@ -89,6 +77,8 @@ COPY server/tsconfig.json ./server/ # raw .ts source — it never enters dist, so it must be copied in explicitly or # `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found. COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts +# Admin recovery script (node server/reset-admin.js) for locked-out installs. +COPY server/reset-admin.js ./server/reset-admin.js COPY --from=shared-builder /app/shared/dist ./shared/dist COPY --from=client-builder /app/client/dist ./server/public COPY --from=client-builder /app/client/public/fonts ./server/public/fonts diff --git a/README.md b/README.md index 3f4d95499..31f8c82d2 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
Dashboard - Trip planner with 3D map + Trip planner · day plan & route Journey journal Costs · expense splitting Atlas · visited countries Vacay planner - Trip planner · day plan and route + Collections · saved place lists Admin panel
diff --git a/charts/README.md b/charts/README.md index 6de5de278..a54ec9b18 100644 --- a/charts/README.md +++ b/charts/README.md @@ -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. diff --git a/charts/trek/templates/NOTES.txt b/charts/trek/templates/NOTES.txt index 0e258f437..0c11d0dc8 100644 --- a/charts/trek/templates/NOTES.txt +++ b/charts/trek/templates/NOTES.txt @@ -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. diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index d79ae3441..09d90dc86 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -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 }} diff --git a/charts/trek/templates/pvc.yaml b/charts/trek/templates/pvc.yaml index b3a069064..5e6872dea 100644 --- a/charts/trek/templates/pvc.yaml +++ b/charts/trek/templates/pvc.yaml @@ -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: diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index a87a4665d..e141aa88f 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -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: diff --git a/client/index.html b/client/index.html index b52109eae..afbfc3541 100644 --- a/client/index.html +++ b/client/index.html @@ -5,6 +5,10 @@ TREK + + + diff --git a/client/package.json b/client/package.json index e924f3042..db94e98c8 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,8 @@ "lint": "eslint .", "lint:check": "eslint .", "lint:pages": "node scripts/check-page-pattern.mjs", + "theme:lint": "node scripts/theme-lint.mjs", + "theme:lint:strict": "node scripts/theme-lint.mjs --strict", "e2e": "playwright test", "e2e:report": "playwright show-report", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", @@ -30,12 +32,14 @@ "@trek/shared": "*", "axios": "^1.6.7", "dexie": "^4.4.2", + "drag-drop-touch": "^1.3.1", "heic-to": "^1.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "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", @@ -48,6 +52,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", + "tz-lookup": "^6.1.25", "zod": "^4.3.6", "zustand": "^4.5.2" }, diff --git a/client/public/theme-boot.js b/client/public/theme-boot.js new file mode 100644 index 000000000..05a9a963a --- /dev/null +++ b/client/public/theme-boot.js @@ -0,0 +1,58 @@ +/* + * Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC). + * + * Loaded as an external, render-blocking CLASSIC script in (NOT a module) + * so it runs before first paint AND complies with the production CSP + * (script-src 'self'; inline scripts are blocked). It reads the compact snapshot + * written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep + * this in sync with that module's snapshot shape + apply logic. + * + * It must never throw — any failure silently falls back to the default look. + */ +(function () { + try { + var raw = localStorage.getItem('trek_appearance'); + if (!raw) return; + var s = JSON.parse(raw); + if (!s || s.v !== 1) return; + + var root = document.documentElement; + var path = location.pathname; + var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0; + + var dark; + if (isShared) dark = false; + else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches; + else dark = s.darkMode === true || s.darkMode === 'dark'; + root.classList.toggle('dark', dark); + + var scheme = isShared ? 'default' : s.scheme; + if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme); + if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', ''); + if (s.density === 'compact') root.setAttribute('data-density', 'compact'); + if (s.reduceMotion) root.setAttribute('data-reduce-motion', ''); + + if (!isShared && scheme === 'custom' && s.accent) { + root.style.setProperty('--accent-custom-light', s.accent.light); + root.style.setProperty('--accent-custom-dark', s.accent.dark); + if (s.accentText) { + root.style.setProperty('--accent-custom-text-light', s.accentText.light); + root.style.setProperty('--accent-custom-text-dark', s.accentText.dark); + } + } + + var ts = s.typeScale || {}; + var fs = typeof s.fontScale === 'number' ? s.fontScale : 1; + setScale('--fs-scale-title', fs * (ts.title || 1)); + setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1)); + setScale('--fs-scale-body', fs * (ts.body || 1)); + setScale('--fs-scale-caption', fs * (ts.caption || 1)); + if (fs !== 1) root.style.fontSize = fs * 100 + '%'; + + function setScale(name, v) { + if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v)); + } + } catch (e) { + /* never block boot */ + } +})(); diff --git a/client/scripts/theme-lint.mjs b/client/scripts/theme-lint.mjs new file mode 100644 index 000000000..adb72e0e7 --- /dev/null +++ b/client/scripts/theme-lint.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/* + * theme:lint — guards the appearance token system. + * + * Flags styling that bypasses the design tokens and therefore won't follow a + * user's chosen scheme / transparency / text-size: + * - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...') + * - inline numeric fontSize (fontSize: 13) + * - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)]) + * + * ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes, + * and genuinely dynamic values (data-driven colors, computed sizes/positions). + * + * Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0; + * `--strict` exits non-zero when any violations remain (for once the backlog is + * burned down, or wired to changed files only). Add `theme-lint-disable` in a + * line comment to suppress an intentional exception (map/PDF/brand colors). + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +let SRC = new URL('../src', import.meta.url).pathname; +if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1); + +// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL +// paint, standalone PDF documents) — colors there must stay literal. +const EXEMPT = [ + /Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/, + /MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/, + /ReservationOverlay/, /\.test\./, /\.spec\./, +]; + +const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/; +const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/; +const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/; + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, files); + else if (/\.(ts|tsx)$/.test(name)) files.push(p); + } + return files; +} + +const strict = process.argv.includes('--strict'); +const offenders = []; +let total = 0; + +for (const f of walk(SRC)) { + if (EXEMPT.some((re) => re.test(f))) continue; + let count = 0; + for (const line of readFileSync(f, 'utf8').split('\n')) { + if (line.includes('theme-lint-disable')) continue; + if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++; + } + if (count) { + offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]); + total += count; + } +} + +offenders.sort((a, b) => b[1] - a[1]); +console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`); +for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`); +if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`); +console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the'); +console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.'); + +if (strict && total > 0) { + console.error(`\n✖ theme:lint:strict — ${total} violations remain.`); + process.exit(1); +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 6bdae8228..6b621dbbf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,7 +2,10 @@ import React, { useEffect, ReactNode } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' +import { applyAppearance } from './theme/applyAppearance' import { useAddonStore } from './store/addonStore' +import { usePluginStore } from './store/pluginStore' +import PluginPage from './pages/PluginPage' import LoginPage from './pages/LoginPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage' @@ -12,14 +15,19 @@ 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' +import CollectionsPage from './pages/CollectionsPage' import JourneyPublicPage from './pages/JourneyPublicPage' import SharedTripPage from './pages/SharedTripPage' +import JoinTripPage from './pages/JoinTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' +import SaveToCollectionModal from './components/Collections/SaveToCollectionModal' +import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' import BottomNav from './components/Layout/BottomNav' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' @@ -105,6 +113,7 @@ export default function App() { const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() + const { loadPlugins } = usePluginStore() useEffect(() => { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { @@ -162,6 +171,7 @@ export default function App() { if (isAuthenticated) { loadSettings() loadAddons() + loadPlugins() } }, [isAuthenticated]) @@ -174,30 +184,21 @@ export default function App() { const isSharedPage = location.pathname.startsWith('/shared/') useEffect(() => { - // Shared page always forces light mode - if (isSharedPage) { - document.documentElement.classList.remove('dark') - const meta = document.querySelector('meta[name="theme-color"]') - if (meta) meta.setAttribute('content', '#ffffff') - return - } - - const mode = settings.dark_mode - const applyDark = (isDark: boolean) => { - document.documentElement.classList.toggle('dark', isDark) - const meta = document.querySelector('meta[name="theme-color"]') - if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff') - } - - if (mode === 'auto') { + const run = () => + applyAppearance({ + darkMode: settings.dark_mode, + appearance: settings.appearance, + isSharedPage, + }) + run() + // Re-resolve on OS theme change while in auto mode. + if (!isSharedPage && settings.dark_mode === 'auto') { const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDark(mq.matches) - const handler = (e: MediaQueryListEvent) => applyDark(e.matches) + const handler = () => run() mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) } - applyDark(mode === true || mode === 'dark') - }, [settings.dark_mode, isSharedPage]) + }, [settings.dark_mode, settings.appearance, isSharedPage]) const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register') @@ -208,6 +209,8 @@ export default function App() { {!isAuthPage && } + {!isAuthPage && } + {!isAuthPage && } } /> @@ -227,6 +230,32 @@ export default function App() { } /> + {/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous + visitor is redirected to /login (never registration) and returns here. */} + + + + } + /> + + + + } + /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } 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), } @@ -373,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) => + apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data), } export const assignmentsApi = { @@ -394,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), @@ -442,6 +460,52 @@ export const adminApi = { updateOidc: (data: Record) => apiClient.put('/admin/oidc', data).then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), + plugins: () => apiClient.get('/admin/plugins').then(r => r.data), + pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data), + pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), + pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), + pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), + pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), + pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), + pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), + pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data), + pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), + pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data), + // Local LLM (Ollama) management for the AI-parsing addon. + llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> => + apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data), + /** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */ + llmLocalPull: async ( + baseUrl: string, + model: string, + onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void, + ): Promise => { + const res = await fetch('/api/admin/llm/local/pull', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseUrl, model }), + }) + if (!res.ok || !res.body) { + let msg = `Pull failed (${res.status})` + try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ } + throw new Error(msg) + } + const reader = res.body.getReader() + const dec = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + try { onProgress(JSON.parse(line)) } catch { /* skip partial */ } + } + } + }, checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), @@ -465,7 +529,8 @@ export const adminApi = { updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data), deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data), listInvites: () => apiClient.get('/admin/invites').then(r => r.data), - createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), + listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data), + createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), auditLog: (params?: { limit?: number; offset?: number }) => apiClient.get('/admin/audit-log', { params }).then(r => r.data), @@ -488,6 +553,14 @@ export const addonsApi = { enabled: () => apiClient.get('/addons').then(r => r.data), } +export const pluginsApi = { + // Active plugins the client renders (page nav entries, dashboard widgets). + active: () => apiClient.get('/plugins').then(r => r.data), + // Call one of a plugin's own declared routes through the host proxy. + invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => + apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data), +} + export const airtrailApi = { getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data), saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) => @@ -538,9 +611,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), @@ -625,17 +705,31 @@ export const reservationsApi = { update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), - importBookingPreview: (tripId: number | string, files: File[]): Promise => { + importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise => { const fd = new FormData() for (const f of files) fd.append('files', f) - return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + fd.append('mode', mode) + // No client-side timeout: kitinerary + LLM extraction routinely exceeds the + // global 8s default (a cold local model alone can take ~45s). + return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data) }, importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise => apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), + // Start a background parse: returns a job id at once; progress + result arrive + // over the WebSocket (import:progress / import:done / import:error). + importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => { + const fd = new FormData() + for (const f of files) fd.append('files', f) + fd.append('mode', mode) + return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data) + }, + // Poll a background job — recovery path when a WebSocket push was missed. + importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> => + apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data), } export const healthApi = { - features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data), + features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data), } export const weatherApi = { @@ -648,6 +742,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 => + 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) => { @@ -727,6 +832,24 @@ export const shareApi = { getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), } +// Public transit routing (#1065) — Transitous/MOTIS proxied through the server. +export const transitApi = { + geocode: (q: string, opts?: { lang?: string; near?: string }) => + apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data), + plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) => + apiClient.get('/transit/plan', { params }).then(r => r.data), +} + +// Trip invite links (#1143) — join a trip as an existing, logged-in user. +export const tripInviteApi = { + getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data), + createLink: (tripId: number | string, expires_in_days?: number | null) => + apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data), + deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data), + preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data), + accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data), +} + export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), @@ -754,4 +877,4 @@ export const inAppNotificationsApi = { apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), } -export default apiClient \ No newline at end of file +export default apiClient diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts new file mode 100644 index 000000000..f16ae4dbb --- /dev/null +++ b/client/src/api/collections.ts @@ -0,0 +1,112 @@ +import apiClient from './client' +import type { AxiosResponse } from 'axios' +import type { + CollectionListResponse, + CollectionDetailResponse, + CollectionSaveResult, + CollectionMembership, + CollectionCreateRequest, + CollectionUpdateRequest, + CollectionSavePlaceRequest, + CollectionSaveFromTripRequest, + CollectionPlaceUpdateRequest, + CollectionCopyToTripRequest, + CollectionInviteRequest, + CollectionRole, + CollectionInviteActionRequest, + CollectionInviteCancelRequest, + CollectionStatus, + Collection, + CollectionPlace, + CollectionLabel, + CollectionLabelCreateRequest, + CollectionLabelUpdateRequest, +} from '@trek/shared' + +const ax = apiClient +const base = '/addons/collections' + +/** Query for the library-wide "is this place already saved?" lookup. */ +export interface MembershipQuery { + google_place_id?: string + google_ftid?: string + name?: string + lat?: number + lng?: number +} + +export interface CopyToTripResult { + copied: number + skipped: { id: number; name: string }[] +} + +/** + * Axios calls for the Collections addon (/api/addons/collections). Mirrors the + * vacayStore api shape — each method returns the unwrapped response body and + * uses `satisfies` on the request payloads so the shared Zod request types stay + * the single source of truth. + */ +export const collectionsApi = { + list: (): Promise => + ax.get(base).then((r: AxiosResponse) => r.data), + get: (id: number): Promise => + ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data), + create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> => + ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data), + update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> => + ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data), + uploadCover: (id: number, formData: FormData): Promise => + ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data), + remove: (id: number): Promise => + ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data), + reorder: (orderedIds: number[]): Promise => + ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data), + + savePlace: (body: CollectionSavePlaceRequest): Promise => + ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data), + saveFromTrip: (body: CollectionSaveFromTripRequest): Promise => + ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data), + saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> => + ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data), + updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise => + ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data), + setStatus: (pid: number, status: CollectionStatus): Promise => + ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data), + deletePlace: (pid: number): Promise => + ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data), + deleteMany: (ids: number[]): Promise => + ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data), + + copyToTrip: (body: CollectionCopyToTripRequest): Promise => + ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data), + membership: (params: MembershipQuery): Promise => + ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data), + + invite: (collectionId: number, userId: number, role?: CollectionRole): Promise => + ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data), + setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise => + ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data), + acceptInvite: (collectionId: number): Promise => + ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data), + declineInvite: (collectionId: number): Promise => + ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data), + cancelInvite: (collectionId: number, userId: number): Promise => + ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data), + leave: (collectionId: number): Promise => + ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data), + removeMember: (collectionId: number, userId: number): Promise => + ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data), + availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> => + ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data), + + createLabel: (collectionId: number, name: string, color?: string): Promise => + ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data), + updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise => + ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data), + deleteLabel: (labelId: number): Promise => + ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data), + assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> => + ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data), + unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> => + ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data), +} diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 458adbf5d..1c658ee11 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,11 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react' +import CustomSelect from '../shared/CustomSelect' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, } function ImmichIcon({ size = 14 }: { size?: number }) { @@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {integrationAddons.map(addon => ( - +
+ + {addon.id === 'llm_parsing' && addon.enabled && ( + + )} +
))} )} @@ -309,6 +315,225 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) } +const MASKED = '••••••••' +const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' + +/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives + * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled + * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ +const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [ + { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false }, +] + +/** + * Instance-wide AI-parsing config. When set, applies to the whole instance and + * overrides per-user config (see server llmConfig.ts). The API key is masked on + * read; an unchanged mask is treated as a no-op by the server. For the local + * provider, it also lists installed Ollama models and can pull NuExtract models. + */ +function LlmParsingConfig({ addon }: { addon: Addon }) { + const toast = useToast() + const cfg = (addon.config ?? {}) as Record + const [provider, setProvider] = useState((cfg.provider as string) ?? 'local') + const [model, setModel] = useState((cfg.model as string) ?? '') + const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? '') + const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? '') + const [saving, setSaving] = useState(false) + + // Local-provider model management. + const [installed, setInstalled] = useState([]) + const [modelsErr, setModelsErr] = useState('') + const [loadingModels, setLoadingModels] = useState(false) + const [pulling, setPulling] = useState(null) + const [pullPct, setPullPct] = useState(0) + const [pullStatus, setPullStatus] = useState('') + + const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL + const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id)) + + const loadModels = async () => { + if (provider !== 'local') return + setLoadingModels(true) + setModelsErr('') + try { + const res = await adminApi.llmLocalModels(effectiveUrl) + setInstalled(res.models.map(m => m.name)) + } catch (e: unknown) { + setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server') + setInstalled([]) + } finally { + setLoadingModels(false) + } + } + + // Load installed models when the local provider is active. + useEffect(() => { + if (provider === 'local') loadModels() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]) + + const pull = async (id: string) => { + if (pulling) return + setPulling(id) + setPullPct(0) + setPullStatus('starting…') + try { + await adminApi.llmLocalPull(effectiveUrl, id, (p) => { + if (p.error) throw new Error(p.error) + if (p.status) setPullStatus(p.status) + if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100)) + }) + toast.success('Model pulled') + setModel(id) + await loadModels() + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Pull failed') + } finally { + setPulling(null) + setPullPct(0) + setPullStatus('') + } + } + + const save = async () => { + setSaving(true) + try { + // Send the masked sentinel unchanged so the server keeps the stored key. + await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } }) + toast.success('Saved') + } catch { + toast.error('Failed to save') + } finally { + setSaving(false) + } + } + + const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none' + const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary' + const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint' + + const providerOptions = [ + { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, + { value: 'openai', label: 'OpenAI', icon: }, + { value: 'anthropic', label: 'Anthropic', icon: }, + ] + + return ( +
+
+

+ Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider. +

+ + {/* Connection */} +
+
Connection
+
+ Provider + setProvider(String(v))} options={providerOptions} /> +
+ {provider !== 'anthropic' && ( + + )} + + {provider === 'anthropic' && ( +

Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.

+ )} +
+ + {/* Model */} +
+
Model
+ + + {/* Local model management (Ollama) */} + {provider === 'local' && ( +
+
+ Installed on the server + +
+ {modelsErr &&

{modelsErr}

} + {!modelsErr && installed.length === 0 && !loadingModels && ( +

No models installed yet — pull one below.

+ )} + {installed.length > 0 && ( +
+ {installed.map(name => ( + + ))} +
+ )} + +
+
Pull a recommended model
+
+ {RECOMMENDED_MODELS.map(m => { + const installedHere = isInstalled(m.id) + const isPulling = pulling === m.id + const active = model === m.id + return ( +
+
+
+ {m.label} + {m.recommended && ( + Recommended + )} +
+
{m.note}
+ {isPulling && ( +
+
+
+
+
{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
+
+ )} +
+ {installedHere ? ( + + ) : ( + + )} +
+ ) + })} +
+
+
+ )} +
+ + +
+
+ ) +} + interface AddonRowProps { addon: Addon onToggle: (addon: Addon) => void diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx new file mode 100644 index 000000000..f030adc83 --- /dev/null +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -0,0 +1,913 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, + ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, + SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, + Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, + BookOpen, Wallet, Puzzle, +} from 'lucide-react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import ConfirmDialog from '../shared/ConfirmDialog' +import ToggleSwitch from '../Settings/ToggleSwitch' + +/** + * Admin → Plugins (#plugins). A full plugin-management surface: a segmented + * Installed/Discover switch, a toolbar (search + type/status filters + sort), an + * updates bar, tidy installed rows that surface each plugin's real reach as + * capability chips, an App-Store-style registry grid, an enriched detail dialog, + * and the update re-consent gate. Isolation health shows as a dot on the icon + * tile; the security section at the bottom explains the model honestly. + */ + +interface PluginRow { + id: string + name: string + description: string | null + type: string + icon: string | null + version: string | null + status: string + enabled: number + last_error: string | null + reviewed_at: string | null + source_repo: string | null + permissions: string + capabilities: string +} +interface RegistryItem { + id: string + name: string + author: string + description: string + repo: string + homepage?: string | null + type: string + latest: string | null + minTrekVersion: string | null + reviewedAt: string | null + screenshotUrl: string | null +} +interface RegistryDetail extends RegistryItem { + size: number | null + publishedAt: string | null + manifest: { + permissions: string[] + egress: string[] + settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> + license: string | null + icon: string | null + } | null +} + +type T = (k: string, p?: Record) => string +type TypeFilter = 'all' | 'widget' | 'page' | 'integration' +type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' +type SortKey = 'name' | 'recent' | 'updates' + +// Runtime health → dot colour on the icon tile. +const HEALTH: Record = { + active: 'bg-success', + starting: 'bg-info animate-pulse', + error: 'bg-danger', + inactive: 'bg-content-faint/60', + disabled: 'bg-warning', + incompatible: 'bg-warning', +} + +// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks. +const ICON_MAP: Record> = { + Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks, +} + +// Known permissions → human-readable i18n key; unknown ones render as raw code. +const PERM_KEYS = [ + 'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', + 'hook:photo-provider', 'hook:calendar-source', 'http:outbound', +] + +const KNOWN_TYPES = ['widget', 'page', 'integration'] + +function isNewer(a: string, b: string): boolean { + const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) + const pa = nums(a), pb = nums(b) + for (let i = 0; i < 3; i++) { + const x = pa[i] || 0, y = pb[i] || 0 + if (x !== y) return x > y + } + return !a.includes('-') && b.includes('-') +} + +function parseJson(raw: string | null | undefined, fallback: T): T { + try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback } +} + +interface Cap { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; net?: boolean } + +// Turn a plugin's declared permissions + capabilities into the at-a-glance chips +// that make its real reach legible without opening the detail dialog. +function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T): Cap[] { + const out: Cap[] = [] + if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) + if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') }) + if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') }) + if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) + if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) + if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) + for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { + out.push({ icon: ArrowRight, label: h, net: true }) + } + return out +} + +function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) { + const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks + return +} + +function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { + if (compact) return + return ( + + {t('admin.plugins.reviewed')} + + ) +} + +function TypeBadge({ type, t }: { type: string; t: T }) { + return ( + + {KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type} + + ) +} + +export default function AdminPluginsPanel() { + const { t, locale } = useTranslation() + const toast = useToast() + const [runtimeOn, setRuntimeOn] = useState(false) + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [busy, setBusy] = useState(null) + const [view, setView] = useState<'installed' | 'discover'>('installed') + const [registry, setRegistry] = useState(null) + const [latest, setLatest] = useState>({}) + const [detailFor, setDetailFor] = useState(null) + const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) + const [confirmUninstall, setConfirmUninstall] = useState(null) + // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — + // each must be shown, not silently overwritten by the last one. + const [consentQueue, setConsentQueue] = useState>([]) + const [menu, setMenu] = useState(null) + + // Toolbar state. + const [q, setQ] = useState('') + const [typeFilter, setTypeFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [sort, setSort] = useState('name') + + const refresh = () => { + adminApi.plugins() + .then((d: { enabled: boolean; plugins: PluginRow[] }) => { + setRuntimeOn(!!d.enabled) + setPlugins(d.plugins || []) + if ((d.plugins || []).length) { + adminApi.pluginBrowse() + .then((items: RegistryItem[]) => { + const map: Record = {} + items.forEach((i) => { if (i.latest) map[i.id] = i.latest }) + setLatest(map) + }) + .catch(() => {}) + } + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)) + } + useEffect(refresh, []) + + const act = async (id: string, fn: () => Promise, ok: string) => { + setBusy(id); setMenu(null) + try { await fn(); toast.success(ok) } + catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) } + finally { setBusy(null); refresh() } + } + + const openDiscover = () => { + setView('discover') + if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([])) + } + const openErrors = (id: string) => { + setMenu(null) + adminApi.pluginErrors(id) + .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors })) + .catch(() => setErrorsFor({ id, rows: [] })) + } + + const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed')) + const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) + const installedIds = new Set(plugins.map(p => p.id)) + + // Enable/disable a plugin. Re-enabling one whose update widened its permissions + // must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we + // route through the same consent dialog the Update button uses. + const toggle = (p: PluginRow) => { + if (busy === p.id) return + if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } + setBusy(p.id); setMenu(null) + adminApi.pluginActivate(p.id) + .then(() => toast.success(t('admin.plugins.activated'))) + .catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => { + const d = e?.response?.data + if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { + setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) + } else { + toast.error(d?.error || t('admin.plugins.actionError')) + } + }) + .finally(() => { setBusy(null); refresh() }) + } + + const runUpdate = (p: PluginRow) => { + setBusy(p.id); setMenu(null) + adminApi.pluginUpdate(p.id) + .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { + if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) + else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) + }) + .catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))) + .finally(() => { setBusy(null); refresh() }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) + + // Installed list after search / type / status filters + sort. + const shownInstalled = useMemo(() => { + const term = q.trim().toLowerCase() + let rows = plugins.filter(p => { + const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term) + const matchesType = typeFilter === 'all' || p.type === typeFilter + const st = statusFilter === 'all' ? true + : statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error' + : statusFilter === 'off' ? p.enabled === 0 + : statusFilter === 'update' ? updateAvailable(p) + : p.status === 'error' + return matchesText && matchesType && st + }) + rows = [...rows].sort((a, b) => { + if (sort === 'updates') { + const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1 + if (ua !== ub) return ua - ub + } + return a.name.localeCompare(b.name) + }) + return rows + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plugins, q, typeFilter, statusFilter, sort, latest]) + + // Registry list after search / type filter. + const shownRegistry = useMemo(() => { + if (!registry) return null + const term = q.trim().toLowerCase() + let items = registry.filter(r => { + const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term) + const matchesType = typeFilter === 'all' || r.type === typeFilter + return matchesText && matchesType + }) + items = [...items].sort((a, b) => a.name.localeCompare(b.name)) + return items + }, [registry, q, typeFilter]) + + const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' + + return ( +
+ {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} + {menu &&
setMenu(null)} />} + {/* Header */} +
+
+
+

{t('admin.plugins.title')}

+

{t('admin.plugins.subtitle')}

+
+ {runtimeOn && ( + + {t('admin.plugins.runtimeOn')} + + )} +
+
+ + {/* Runtime-disabled notice */} + {!runtimeOn && !loading && !error && ( +
+ +
+

{t('admin.plugins.disabledTitle')}

+

{t('admin.plugins.disabledBody')}

+
+
+ )} + + {/* Toolbar — on mobile: tabs+rescan, then full-width search, then a + right-aligned filter row. On sm+ the wrappers collapse (sm:contents) + so everything sits in one wrapping row. */} + {runtimeOn && !loading && !error && ( +
+
+
+ setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} /> + +
+ +
+ +
+ + setQ(e.target.value)} type="search" + placeholder={t('admin.plugins.searchPlaceholder')} + className="w-full h-[38px] pl-9 pr-3 rounded-xl border border-edge bg-surface-secondary text-sm text-content placeholder:text-content-faint outline-none focus:bg-surface-card focus:border-content-faint transition-colors" + /> +
+ +
+ } + options={[ + ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], + ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], + ]} + valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} + onPick={v => setTypeFilter(v as TypeFilter)} /> + + {view === 'installed' && ( + } + options={[ + ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], + ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], + ]} + valueLabel={statusLabel(statusFilter, t)} + onPick={v => setStatusFilter(v as StatusFilter)} /> + )} + + } + options={[['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} + valueLabel={sortLabel(sort, t)} + onPick={v => setSort(v as SortKey)} /> + + +
+
+ )} + + {/* Body */} +
+ {loading ? ( +
{t('common.loading')}
+ ) : error ? ( +
{t('admin.plugins.loadError')}
+ ) : !runtimeOn ? null : view === 'discover' ? ( + + ) : plugins.length === 0 ? ( + + ) : ( +
+ {updatable.length > 0 && statusFilter !== 'err' && ( +
+ + {t('admin.plugins.updatesAvailable', { count: updatable.length })} + +
+ )} + {shownInstalled.length === 0 ? ( +
+ +

{t('admin.plugins.noMatchInstalled')}

+
+ ) : shownInstalled.map(p => ( + toggle(p)} + onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + ))} +
+ )} +
+ + + + {/* Registry detail dialog */} + {detailFor && ( + setDetailFor(null)} /> + )} + + {/* Error-log modal */} + {errorsFor && ( +
setErrorsFor(null)}> +
e.stopPropagation()}> +
+ {errorsFor.id} — {t('admin.plugins.errorLog')} + +
+
+ {errorsFor.rows.length === 0 ?

{t('admin.plugins.noErrors')}

: + errorsFor.rows.map((r, i) => ( +
+ {r.level} + {r.ts} + {r.message} +
+ ))} +
+
+
+ )} + + setConfirmUninstall(null)} + onConfirm={async () => { + const p = confirmUninstall!; setConfirmUninstall(null) + await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) + }} + title={t('admin.plugins.uninstallTitle')} + message={t('admin.plugins.uninstallBody')} + /> + + {consentQueue[0] && ( + { + const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1)) + // consent:true — the ONLY path that may widen a plugin's granted rights. + await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')) + }} + onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} + /> + )} +
+ ) +} + +function statusLabel(s: StatusFilter, t: T): string { + return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active') + : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error') +} +function sortLabel(s: SortKey, t: T): string { + return s === 'name' ? t('admin.plugins.sortName') : s === 'recent' ? t('admin.plugins.sortRecent') : t('admin.plugins.sortUpdates') +} + +function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { + return ( + + ) +} + +function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: { + id: string; label: string; valueLabel: string + options: Array<[string, string]>; onPick: (v: string) => void; value: string + menu: string | null; setMenu: (v: string | null) => void; icon?: React.ReactNode +}) { + const open = menu === id + const active = value !== options[0]?.[0] + return ( +
+ + {open && ( +
+ {options.map(([v, lbl]) => ( + + ))} +
+ )} +
+ ) +} + +function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { + return ( +
+
+ +
+

{t('admin.plugins.empty')}

+ +
+ ) +} + +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onUninstall }: { + p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void + hasUpdate: boolean; latestVer?: string + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void +}) { + const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) + const menuOpen = menu === `row:${p.id}` + return ( +
+
+
+ +
+ +
+ +
+
+ {p.name} + {p.version && v{p.version}} + {p.reviewed_at && } +
+ {p.description &&

{p.description}

} + {p.status === 'error' && p.last_error ? ( +
+ {p.last_error} +
+ ) : caps.length > 0 && ( +
+ {caps.map((c, i) => ( + + {c.label} + + ))} +
+ )} +
+ +
+ {hasUpdate && ( + + )} + + {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')} + + +
+ + {menuOpen && ( +
+ {p.enabled === 1 && ( + } label={t('admin.plugins.restart')} onClick={onRestart} /> + )} + } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> + {p.source_repo && ( + setMenu(null)} + className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> + {t('admin.plugins.sourceRepo')} + + )} +
+ } label={t('common.delete')} danger onClick={onUninstall} /> +
+ )} +
+
+
+ ) +} + +function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { + return ( + + ) +} + +function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { + const [failed, setFailed] = useState(false) + return ( +
+ {url && !failed ? ( + setFailed(true)} /> + ) : ( +
+ +
+ )} +
+ ) +} + +function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: { + items: RegistryItem[] | null + onInstall: (id: string) => void + onOpenDetail: (item: RegistryItem) => void + busy: string | null + t: T + installedIds: Set + filtered: boolean +}) { + if (!items) return
{t('common.loading')}
+ if (items.length === 0) return ( +
+ +

{filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')}

+
+ ) + return ( +
+ {items.map(item => { + const installed = installedIds.has(item.id) + return ( +
onOpenDetail(item)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} + className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150"> +
+ +
+ {item.reviewedAt && ( + + {t('admin.plugins.reviewed')} + + )} +
+ +
+
+
+ {item.name} + {item.author} +

{item.description}

+
+ + {item.latest && v{item.latest}} + +
+
+
+ ) + })} +
+ ) +} + +// A permission rendered human-readable when known, else as its raw code. +function PermLabel({ perm, t }: { perm: string; t: T }) { + return PERM_KEYS.includes(perm) + ? {t(`admin.plugins.perm.${perm}` as never)} + : {perm} +} + +function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: { + item: RegistryItem; installed: boolean; busy: string | null + onInstall: (id: string) => void; onClose: () => void; t: T; locale: string +}) { + const [detail, setDetail] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let alive = true + adminApi.pluginDetail(item.id) + .then((d: RegistryDetail) => { if (alive) setDetail(d) }) + .catch(() => { if (alive) setFailed(true) }) + return () => { alive = false } + }, [item.id]) + + const manifest = detail?.manifest ?? null + const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : [] + const repoUrl = `https://github.com/${item.repo}` + const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null + const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null + + return ( +
+
e.stopPropagation()}> +
+ +
+ +
+ +
+
+ +
+
+
+

{item.name}

+ {item.reviewedAt && } +
+

{item.author}{item.latest ? ` · v${item.latest}` : ''}

+
+ +
+ +
+

{item.description}

+ {failed &&

{t('admin.plugins.detailError')}

} + + {manifest && ( +
+

{t('admin.plugins.accessTitle')}

+ {caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? ( +

{t('admin.plugins.noAccess')}

+ ) : ( +
+ {caps.filter(c => !c.net).map((c, i) => ( +
+ {c.label} +
+ ))} + {manifest.permissions.includes('db:own') && ( +
+ {t('admin.plugins.perm.db:own')} +
+ )} +
+ )} +
+ )} + + {manifest && manifest.egress.length > 0 && ( +
+

{t('admin.plugins.connectsTitle')}

+
+ {manifest.egress.map(h => ( + {h} + ))} +
+
+ )} + + {manifest && manifest.settings.length > 0 && ( +
+

{t('admin.plugins.setupTitle')}

+
    + {manifest.settings.map(s => ( +
  • + {s.label} + {t(`admin.plugins.scope.${s.scope}` as never)} + {s.required && {t('admin.plugins.fieldRequired')}} +
  • + ))} +
+
+ )} + +
+

{t('admin.plugins.detailsTitle')}

+
+ {item.latest && } + {sizeKb && } + {item.minTrekVersion && } + {item.reviewedAt && } +
+
+
+ + +
+
+ ) +} + +function Meta({ k, v }: { k: string; v: string }) { + return
{k}
{v}
+} + +function UpdateConsentDialog({ data, t, onApprove, onLater }: { + data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } + t: T; onApprove: () => void; onLater: () => void +}) { + return ( +
+
e.stopPropagation()}> +
+
+
+

{t('admin.plugins.updateConsentTitle')}

+

{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}

+
+
+
+ {data.newPermissions.length > 0 && ( +
+

{t('admin.plugins.updateNewPermissions')}

+
    + {data.newPermissions.map(perm => ( +
  • + ))} +
+
+ )} + {data.newEgress.length > 0 && ( +
+

{t('admin.plugins.updateNewEgress')}

+
+ {data.newEgress.map(host => {host})} +
+
+ )} +
+
+ + +
+
+
+ ) +} + +// Footer: a plain-language note on what "Reviewed" means, plus a collapsible +// panel that lays out how plugins are contained, the limits, and the worst case. +function SecurityInfo({ t }: { t: T }) { + const [open, setOpen] = useState(false) + const sections: Array<[string, string]> = [ + ['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'], + ['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'], + ['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'], + ['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'], + ['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'], + ['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'], + ] + return ( +
+
+ +

{t('admin.plugins.reviewedMeaning')}

+
+ + {open && ( +
+ {sections.map(([h, b]) => ( +
+

{t(h as never)}

+

{t(b as never)}

+
+ ))} +
+ )} +
+ ) +} diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 6dc67c86f..e7d18f1a8 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -473,10 +473,10 @@ export default function BackupPanel() {
-

+

{t('backup.restoreConfirmTitle')}

-

+

{restoreConfirm.filename}

@@ -484,11 +484,11 @@ export default function BackupPanel() { {/* Body */}
-

+

{t('backup.restoreWarning')}

-
{t('backup.restoreTip')} @@ -500,14 +500,14 @@ export default function BackupPanel() { + ) : ( +
{t('reservations.import.previewEmpty')}
+ ) + )} + + {task.status === 'error' && ( +
{task.error}
+ )} +
+ + {task.status !== 'running' && ( + + )} +
+ ))} +
, + document.body + ) +} diff --git a/client/src/components/Budget/BudgetPanel.constants.ts b/client/src/components/Budget/BudgetPanel.constants.ts index d5f2bd8a5..d6bd5d7af 100644 --- a/client/src/components/Budget/BudgetPanel.constants.ts +++ b/client/src/components/Budget/BudgetPanel.constants.ts @@ -2,8 +2,8 @@ export const CURRENCIES = [ 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', - 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT', - 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', + 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS', + 'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', ] export const SYMBOLS: Record = { @@ -13,8 +13,8 @@ export const SYMBOLS: Record = { PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', - UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$', - PEN: 'S/.', ARS: 'AR$', + UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', + COP: 'CO$', PEN: 'S/.', ARS: 'AR$', } export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 57bc843da..7ef1d3f24 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -38,14 +38,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
-

{t('budget.emptyTitle')}

-

{t('budget.emptyText')}

+

{t('budget.emptyTitle')}

+

{t('budget.emptyText')}

{canEdit && (
setNewCategoryName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddCategory()} placeholder={t('budget.emptyPlaceholder')} - style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> + style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
- {fmt(subtotal, currency)} + {fmt(subtotal, currency)} {canEdit && (
) : ( - {item.expense_date || '—'} + {item.expense_date || '—'} )} handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> diff --git a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx index c74c8bf5f..5dae2a574 100644 --- a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx +++ b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx @@ -50,7 +50,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = { return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} - style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} + style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} placeholder={placeholder} /> } @@ -62,7 +62,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', - color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} + color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }} onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> {display || placeholder || '-'} diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx index c5d0c1902..c042638c1 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -7,6 +7,7 @@ export interface TripMember { id: number username: string avatar_url?: string | null + is_guest?: boolean } // ── Chip with custom tooltip ───────────────────────────────────────────────── @@ -56,13 +57,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 5, background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', - fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8, + 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)', }}> {label} {paid && ( Paid @@ -151,14 +152,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
) diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx index 693ea9862..70abfae18 100644 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
-
{t('budget.totalBudget')}
+
{t('budget.totalBudget')}
@@ -58,13 +58,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] return (
- {integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} + {integerPart} + {decimalPart && {sep}{decimalPart}} + {SYMBOLS[currency] || currency}
) })()} -
+
{currency}
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
@@ -117,7 +117,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has >
- + {fmt(flow.amount, currency)}
@@ -130,7 +130,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
-
+
{t('budget.netBalances')}
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has return (
- + {b.username} @@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
-
{t('budget.byCategory')}
+
{t('budget.byCategory')}
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has })}
-
{t('budget.total')}
-
+
{t('budget.total')}
+
{totalInt} - {totalDec && {decimalSep}{totalDec}} + {totalDec && {decimalSep}{totalDec}}
-
{currency}
+
{currency}
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has boxShadow: `0 0 12px ${seg.color}80`, }} />
-
{seg.name}
-
{fmt(seg.value, currency)}
+
{seg.name}
+
{fmt(seg.value, currency)}
{ 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 | 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 | 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 + 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() + + 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:') + }) }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 5111eb3b9..2a35aba18 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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 { + 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 = {} + 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 +} + +export function calculateTicketShares(items: TicketItem[]): { shares: Record; total: number } { + const shares: Record = {} + 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 = {} + for (const id of Object.keys(shares)) { + finalShares[Number(id)] = shares[Number(id)] / 100 + } + + return { shares: finalShares, total: totalCents / 100 } +} interface CostsPanelProps { tripId: number @@ -68,6 +131,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [settlement, setSettlement] = useState(null) const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') const [search, setSearch] = useState('') + const [catFilter, setCatFilter] = useState('') // '' = all categories + const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [editingSettlement, setEditingSettlement] = useState(null) @@ -105,9 +170,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(() => { @@ -124,21 +194,27 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps let list = budgetItems.slice() if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0) if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0) + // catMeta normalises legacy/free-text categories to the fixed keys, so the + // filter matches rows saved before the category rework too. + if (catFilter) list = list.filter(e => catMeta(e.category).key === catFilter) + if (dayFilter) list = list.filter(e => (e.expense_date || '') === dayFilter) const q = search.trim().toLowerCase() if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) return list - }, [budgetItems, filter, search, me]) + }, [budgetItems, filter, search, catFilter, dayFilter, me]) // Settlements ("payments") shown inline in the ledger. They have no name, so a // text search hides them; they're excluded from the "owed" expense filter and, // under "mine", only show transfers I'm part of. const filteredSettlements = useMemo(() => { - if (search.trim()) return [] + // Payments carry no name or category, so a text/category filter hides them. + if (search.trim() || catFilter) return [] if (filter === 'owed') return [] let list = settlement?.settlements || [] if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) + if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter) return list - }, [settlement, filter, search, me]) + }, [settlement, filter, search, catFilter, dayFilter, me]) const dayGroups = useMemo(() => { const entries: LedgerEntry[] = [ @@ -161,6 +237,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return groups }, [filtered, filteredSettlements, locale, t]) + // ── filter dropdown options (category + single day) ────────────────────── + const categoryOptions = useMemo(() => [ + { value: '', label: t('costs.filter.allCategories') }, + ...COST_CATEGORY_LIST.map(c => ({ value: c.key, label: t(c.labelKey), icon: })), + ], [t]) + + const dayOptions = useMemo(() => { + const days = Array.from(new Set(budgetItems.map(e => e.expense_date).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a)) + const fmtDay = (d: string) => { + try { return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return d } + } + return [{ value: '', label: t('costs.filter.allDays') }, ...days.map(d => ({ value: d, label: fmtDay(d) }))] + }, [budgetItems, locale, t]) + // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { @@ -215,6 +305,29 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return <>{parts.map((p, i) => {p.value})} } + // ── category + day filter controls (shared by both layouts) ────────────── + const filterControls = ( + <> + setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ minWidth: 148 }} /> + setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ minWidth: 140 }} /> + + ) + + // A prominent summary shown when a single day is selected: the day + its total. + const dayFilterTotal = dayFilter ? filtered.reduce((a, e) => a + baseTotal(e), 0) : 0 + const dayFilterLabel = dayFilter + ? (() => { try { return new Date(dayFilter + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) } catch { return dayFilter } })() + : '' + const dayBanner = dayFilter ? ( +
+
+
{dayFilterLabel}
+
{t('costs.expensesCount', { count: filtered.length })}
+
+
{bigMoney(dayFilterTotal, 15, 'var(--text-muted)')}
+
+ ) : null + return (
{isMobile ? : ( @@ -223,17 +336,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{dateMeta && ( - + {dateMeta.range} · {t('costs.daysCount', { count: dateMeta.days })} )} - + {people.slice(0, 4).map((p, i) => { const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const return p.avatar_url ? - : {(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()} + : {(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()} })} {t('costs.travelers', { count: people.length })} @@ -243,12 +356,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
@@ -277,20 +390,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* expenses */}
-

+

{t('costs.expenses')}

-
+
setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} - className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} /> + className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} />
+ {filterControls}
{(['all', 'mine', 'owed'] as const).map(f => ( ))} @@ -298,6 +412,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
+ {dayBanner} {dayGroups.length === 0 ? (
{search ? t('costs.noMatch') : t('costs.emptyText')} @@ -306,9 +421,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
+ {!dayFilter && (
- {g.day}{t('costs.spent', { amount: fmt(dtot) })} + {g.day}{t('costs.spent', { amount: fmt(dtot) })}
+ )}
{g.entries.map(en => en.kind === 'expense' ? @@ -328,7 +445,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {canEdit && ( )} @@ -407,8 +524,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps if (flows.length === 0) return (
-
{t('costs.everyoneSquare')}
-
{t('costs.nothingOutstanding')}
+
{t('costs.everyoneSquare')}
+
{t('costs.nothingOutstanding')}
) return ( @@ -419,8 +536,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
- {fmt(f.amount)} - {canEdit && } + {fmt(f.amount)} + {canEdit && }
))} @@ -434,14 +551,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Total card */}
-
{t('costs.totalSpend')}
-
{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}
-
+
{t('costs.totalSpend')}
+
{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}
+
{t('costs.yourShare')} · {fmt0(totals.myShare)} {t('costs.youPaid')} · {fmt0(totals.myPaid)}
{canEdit && ( - )} @@ -451,24 +568,24 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
-
{t('costs.youOwe')}
-
{t('costs.youOweSub')}
-
{bigMoney(totals.owe, 16, 'var(--c-ink3)')}
+
{t('costs.youOwe')}
+
{t('costs.youOweSub')}
+
{bigMoney(totals.owe, 16, 'var(--c-ink3)')}
-
{t('costs.youreOwed')}
-
{t('costs.youreOwedSub')}
-
{bigMoney(totals.owed, 16, 'var(--c-ink3)')}
+
{t('costs.youreOwed')}
+
{t('costs.youreOwedSub')}
+
{bigMoney(totals.owed, 16, 'var(--c-ink3)')}
{/* Settle up */}
-
{t('costs.settleUp')} {(settlement?.flows || []).length}
+
{t('costs.settleUp')} {(settlement?.flows || []).length}
{canEdit && ( - + )}
@@ -476,23 +593,28 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* Expenses */}
-
{t('costs.expenses')}
+
{t('costs.expenses')}
- setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} /> + setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} />
{(['all', 'mine', 'owed'] as const).map(f => ( - + ))}
+
+ setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ flex: 1, minWidth: 0 }} /> + setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ flex: 1, minWidth: 0 }} /> +
+ {dayBanner} {dayGroups.length === 0 - ?
{search ? t('costs.noMatch') : t('costs.emptyText')}
+ ?
{search ? t('costs.noMatch') : t('costs.emptyText')}
: dayGroups.map(g => { const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
-
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
+ {!dayFilter &&
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
}
{g.entries.map(en => en.kind === 'expense' ? : )}
@@ -531,15 +653,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {isMobile && isUnfinished && ( - ! + ! )}
- {e.name} + {e.name} {isUnfinished && !isMobile && ( - - ! + + ! {t('costs.unfinished')} )} @@ -547,7 +669,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {payers.length > 0 && (
{payers.map(p => ( - + {fmt(convert(p.amount, cur))} @@ -555,16 +677,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)} {!isMobile && ( -
+
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
)}
-
{fmt(baseTotal(e))}
+
{fmt(baseTotal(e))}
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( -
0 ? '#16a34a' : '#dc2626' }}> +
0 ? '#16a34a' : '#dc2626' }}> {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
)} @@ -587,14 +709,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
-
{t('costs.payment')}
+
{t('costs.payment')}
- {personName(s.from_user_id)} → {personName(s.to_user_id)} + {personName(s.from_user_id)} → {personName(s.to_user_id)}
-
{fmt(s.amount)}
+
{fmt(s.amount)}
{canEdit && (
@@ -618,14 +740,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
-
{personName(r.user_id)}
+
{personName(r.user_id)}
{pos && } {neg && }
-
+
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
@@ -639,7 +761,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const tot: Record = {} for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) - if (rows.length === 0) return
{t('costs.noCategories')}
+ if (rows.length === 0) return
{t('costs.noCategories')}
// Bars are scaled relative to the most expensive category (the top row fills the // bar), not to the trip grand total — makes the relative ranking readable. const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0)) @@ -650,8 +772,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return (
- {t(c.labelKey)} - {fmt0(v)} + {t(c.labelKey)} + {fmt0(v)}
@@ -682,16 +804,16 @@ function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }:
{icon}
-
{label}
-
{sub}
+
{label}
+
{sub}
-
+
{parts - ? parts.map((p, i) => {p.value}) + ? parts.map((p, i) => {p.value}) : {formatMoney(amount, currency, locale)}}
-
{foot}
+
{foot}
) } @@ -702,7 +824,7 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A {lead} {uniq.map(id => ( - + {name(id)} ))} @@ -746,8 +868,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { - - + +
}>
@@ -762,7 +884,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} /> + onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none', fontWeight: 600 }} />
@@ -790,11 +912,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [cat, setCat] = useState(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(() => { if (editing) return editing.total_price ? String(editing.total_price) : '' if (prefill?.amount != null) return String(prefill.amount) @@ -802,89 +919,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }) const [participants, setParticipants] = useState>(() => editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) - const [paid, setPaid] = useState>(() => { + + // Payer state: 0 represents "Nobody (planning entry)" + const [payerId, setPayerId] = useState(() => { + 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(() => { + 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>(() => { const m: Record = {} - 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>(() => - 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, dirtySet: Set, parts: Set, totalVal: number): Record => { - 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' @@ -894,23 +1114,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo - - + +
}>
- setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} /> + setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} />
-
- {sym(currency)} - + {sym(currency)} + onTotalChange(e.target.value)} - className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} /> + 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%' }} />
@@ -927,7 +1148,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{currency !== base && totalNum > 0 && ( -
+
{formatMoney(totalNum, currency, locale)} ≈ {formatMoney(convert(totalNum, currency), base, locale)} @@ -943,7 +1164,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return ( @@ -954,39 +1175,165 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
-
- {people.map((p, idx) => { - const on = participants.has(p.id) - return ( -
- - {on ? ( -
- {sym(currency)} - onPaidChange(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' }} /> + 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%' }} /> +
+ +
+
+ +
+ + + +
+
+ {splitMode === 'ticket' ? ( +
+ +
+ {ticketItems.map((item, itemIdx) => ( +
+
+ 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' }} + /> +
+ {sym(currency)} + 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' }} + /> +
+
- ) : ( - - )} + +
+ Splitting: + {people.map((p, pIdx) => { + const active = item.participants.has(p.id) + return ( + + ) + })} +
+
+ ))} +
+ + + + {ticketItems.length > 0 && ( +
+
Individual Shares Summary
+
+ {people.map(p => { + const share = ticketInfo.shares[p.id] || 0 + return ( +
+ {p.id === me ? t('costs.you') : p.username} + {sym(currency)}{share.toFixed(2)} +
+ ) + })} +
- ) - })} -
-
- - {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} - - {paidEntered - ? {sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)} - : (totalNum > 0 && {t('costs.unfinishedHint')})} -
+ )} +
+ ) : ( + <> +
+ {people.map((p, idx) => { + const on = participants.has(p.id) + return ( +
+ + {splitMode === 'equally' ? ( + on ? ( + + {sym(currency)}{(equalShares[p.id] || 0).toFixed(2)} + + ) : ( + Excluded + ) + ) : ( + on ? ( +
+ {sym(currency)} + 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' }} /> +
+ ) : ( + + ) + )} +
+ ) + })} +
+
+ {splitMode === 'equally' ? ( + + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + + ) : ( + + {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)})`} + + )} +
+ + )}
diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index 072cbd628..924361f79 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -647,7 +647,7 @@ describe('CollabChat', () => { let foundBigEmoji = false; while (el) { const styleAttr = el.getAttribute('style'); - if (styleAttr && styleAttr.includes('font-size: 40px')) { + if (styleAttr && styleAttr.includes('font-size: calc(40px')) { foundBigEmoji = true; break; } diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 94b7f1b0b..8fef5880e 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { disabled={!canEdit} style={{ flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20, - padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit', + padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none', maxHeight: 100, overflowY: 'hidden', opacity: canEdit ? 1 : 0.5, diff --git a/client/src/components/Collab/CollabChatEmojiPicker.tsx b/client/src/components/Collab/CollabChatEmojiPicker.tsx index 67d1b310e..3ee269667 100644 --- a/client/src/components/Collab/CollabChatEmojiPicker.tsx +++ b/client/src/components/Collab/CollabChatEmojiPicker.tsx @@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj diff --git a/client/src/components/Collab/CollabChatLinkPreview.tsx b/client/src/components/Collab/CollabChatLinkPreview.tsx index 70d282b21..b8a722645 100644 --- a/client/src/components/Collab/CollabChatLinkPreview.tsx +++ b/client/src/components/Collab/CollabChatLinkPreview.tsx @@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { )}
{domain && ( -
+
{data.site_name || domain}
)} {data.title && ( -
+
{data.title}
)} {data.description && ( -
+
{data.description}
)} diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx index 37a4661d0..6c5abab98 100644 --- a/client/src/components/Collab/CollabChatMessages.tsx +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -14,8 +14,8 @@ export function ChatMessages(props: any) { {messages.length === 0 ? (
- {t('collab.chat.empty')} - {t('collab.chat.emptyDesc') || ''} + {t('collab.chat.empty')} + {t('collab.chat.emptyDesc') || ''}
) : (
{hover && names && ReactDOM.createPortal(
{names} diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 1a1556f84..9f7ac46e0 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -243,7 +243,7 @@ function CollabNotesLoading({ t }: NotesState) { return (
-

+

{t('collab.notes.title')}

@@ -263,7 +263,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not return (

@@ -277,7 +277,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not } {canEdit && } @@ -292,7 +292,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t diff --git a/client/src/components/Collab/CollabNotesEditableCatName.tsx b/client/src/components/Collab/CollabNotesEditableCatName.tsx index e205d0703..35f542608 100644 --- a/client/src/components/Collab/CollabNotesEditableCatName.tsx +++ b/client/src/components/Collab/CollabNotesEditableCatName.tsx @@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) { if (editing) { return setValue(e.target.value)} onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }} - style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} /> + style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} /> } return ( { setValue(name); setEditing(true) }} - style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }} + style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }} title="Click to rename"> {name} diff --git a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx index 2c02bef98..20d9a3635 100644 --- a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx +++ b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx @@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { : }
- {file.original_name} + {file.original_name}
@@ -48,21 +48,21 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { /* Document viewer — card with header */
e.stopPropagation()}>
- {file.original_name} + {file.original_name}
- +
{(isPdf || isTxt) ? (

- +

) : (
- +
)}
diff --git a/client/src/components/Collab/CollabNotesFormModal.tsx b/client/src/components/Collab/CollabNotesFormModal.tsx index 90d00cb7d..5b7f83287 100644 --- a/client/src/components/Collab/CollabNotesFormModal.tsx +++ b/client/src/components/Collab/CollabNotesFormModal.tsx @@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor borderBottom: '1px solid var(--border-faint)', }}>

-
+
{t('collab.notes.category')}
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor const active = category === cat return ( ) @@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor {/* Website */}
-
+
{t('collab.notes.website')}
setWebsite(e.target.value)} placeholder={t('collab.notes.websitePlaceholder')} - style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> + style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
{/* File attachments */} {canUploadFiles &&
-
+
{t('collab.notes.attachFiles')}
{ const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} /> @@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor {existingAttachments.map(a => { const isImage = a.mime_type?.startsWith('image/') return ( -
+
{isImage && } {(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
))}
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor padding: '7px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)', color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', - fontSize: 12, + fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', diff --git a/client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx b/client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx index 7a864cf59..c1136e21a 100644 --- a/client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx +++ b/client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx @@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) ) : ( <> - + {domain} diff --git a/client/src/components/Collab/CollabPanel.tsx b/client/src/components/Collab/CollabPanel.tsx index 13c553a2d..0489769ff 100644 --- a/client/src/components/Collab/CollabPanel.tsx +++ b/client/src/components/Collab/CollabPanel.tsx @@ -175,7 +175,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures } padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer', background: active ? 'var(--accent)' : 'transparent', color: active ? 'var(--accent-text)' : 'var(--text-muted)', - fontSize: 11, fontWeight: 600, fontFamily: 'inherit', + fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', }}> {tab.label} diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx index 407a03c32..b86c7a37e 100644 --- a/client/src/components/Collab/CollabPolls.tsx +++ b/client/src/components/Collab/CollabPolls.tsx @@ -88,30 +88,30 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
e.stopPropagation()} onSubmit={handleSubmit}>
-

{t('collab.polls.new')}

+

{t('collab.polls.new')}

{/* Question */}
-
{t('collab.polls.question')}
- setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> +
{t('collab.polls.question')}
+ setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
{/* Options */}
-
{t('collab.polls.options')}
+
{t('collab.polls.options')}
{options.map((opt, i) => (
updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`} - style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} /> + style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} /> {options.length > 2 && ( )}
))} -
@@ -126,13 +126,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { }}>
- {t('collab.polls.multiChoice')} + {t('collab.polls.multiChoice')} {/* Submit */} @@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) { style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', + fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0, }}> {voter.avatar_url ? : (voter.username || '?')[0].toUpperCase()} @@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) { position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', background: 'var(--bg-card)', color: 'var(--text-primary)', - fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8, + 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)', }}> {voter.username} @@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: background: isClosed ? 'var(--bg-secondary)' : 'transparent', }}>
-
+
{poll.question}
{isClosed && ( - + {t('collab.polls.closed')} )} {remaining && !isClosed && ( - + {remaining} )} {poll.multi_choice && ( - + {t('collab.polls.multiChoice')} )} - + {total} {total === 1 ? 'vote' : 'votes'}
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: {/* Label */} {typeof opt === 'string' ? opt : opt.text} @@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: {/* Percentage */} {(hasVoted || isClosed) && ( {pct}% @@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{/* Header */}
-

+

{t('collab.polls.title')}

{canEdit && ( + +
+ } + > +
+ {/* Search — picking a result fills the location below */} +
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }} + placeholder={t('collections.addPlaceSearch')} + className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" + /> +
+ +
+ {results.length > 0 && ( +
+
+ {t('common.search')} + +
+ {results.map((r, i) => ( + + ))} +
+ )} +
+ + {/* Name */} +
+ + setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" /> + {address &&
{address}
} +
+ + {/* Status */} +
+
+ {STATUS_ORDER.map(s => { + const Icon = STATUS_META[s].icon + const on = status === s + return ( + + ) + })} +
+
+ + {/* Category */} + {categories.length > 0 && ( +
+ +
+ + {categories.map(cat => { + const Icon = getCategoryIcon(cat.icon ?? undefined) + const on = categoryId === cat.id + const col = cat.color || '#6366f1' + return ( + + ) + })} +
+
+ )} + + {/* Description */} +
+ + +