mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 05:41:47 +00:00
Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 80d013dd19 | |||
| 929105f0e4 | |||
| 93c0d6fe78 | |||
| 88a40c3294 | |||
| c056401000 | |||
| eae799c7d6 | |||
| 20ce7460c1 | |||
| d765a80ea3 | |||
| 1dc189b466 | |||
| e624ee337f | |||
| 6ba5df0215 | |||
| 897e1bff26 | |||
| ba14636c1d | |||
| 6c72295424 | |||
| f6faaa23b0 | |||
| 98813a9b40 | |||
| e0105115f4 | |||
| 217458da81 | |||
| 8dd22ab8a3 | |||
| cfdbf9235f | |||
| 059158d087 | |||
| 77393ff40b | |||
| 64d4a20403 | |||
| 6b94c0632c | |||
| cb124ba3ec | |||
| ba01b4acac | |||
| ce72f45d9a | |||
| bf2eea18c3 | |||
| 501bab0f69 | |||
| 5dd80d5cb8 | |||
| 8f6de3cd23 | |||
| 816696d0fe | |||
| bb54fda6dc | |||
| 36f2292f2d | |||
| 905c7d460b | |||
| d48714d17a | |||
| a0db42fbfe | |||
| f4d0ccb454 | |||
| a40983e65e | |||
| f32c103fe1 | |||
| 0b77fe5292 | |||
| 9afb51fcc0 | |||
| 4e10028669 | |||
| d4e16ebe49 | |||
| 4ff03a1f2c | |||
| 40f7c00adb | |||
| b43d8d119f | |||
| 74e3f85866 | |||
| bbf3f0cae8 | |||
| c0e9a771d6 | |||
| c49272efc1 | |||
| 979322025d | |||
| f0131632a7 | |||
| ffe91604b5 | |||
| e7fa8f5da9 | |||
| 3256f5156d | |||
| d45073a0bd | |||
| a4d6348a79 | |||
| c944a7d101 | |||
| 45e0c7e546 | |||
| 32b63adc68 | |||
| b1cca15f6f | |||
| dfeb7b3db7 | |||
| 50424fc574 | |||
| 12a910876e | |||
| d73a5e223c | |||
| fd9567e3fe | |||
| ae04071466 | |||
| 2ab3f59722 | |||
| 7257fac859 | |||
| 1a4c04e239 | |||
| 39a495714f | |||
| fabf5a7e26 | |||
| e71bd6768e | |||
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 505bf04a1f | |||
| 41bfcf2f76 | |||
| e308204808 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 |
+23
-1
@@ -5,6 +5,28 @@ client/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
.env
|
||||
.github
|
||||
**/.env
|
||||
**/.env.*
|
||||
*.log
|
||||
*.md
|
||||
!client/**/*.md
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/coverage
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve TREK
|
||||
title: "[BUG] "
|
||||
labels: []
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: TREK version
|
||||
description: Found in the Settings → About, or in the Docker image tag
|
||||
placeholder: "e.g. 2.8.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: When I do X, Y happens instead of Z…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Step-by-step instructions to reliably trigger the bug.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment method
|
||||
options:
|
||||
- Docker Compose
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Host OS
|
||||
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
|
||||
|
||||
- type: dropdown
|
||||
id: user_os
|
||||
attributes:
|
||||
label: Accessing TREK from
|
||||
options:
|
||||
- Desktop browser
|
||||
- Mobile browser
|
||||
- Mobile app (PWA)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs or error output
|
||||
description: Paste any relevant server or browser console output here.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Drag and drop screenshots here if applicable.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that might help us understand the issue.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Close untitled issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const badTitles = [
|
||||
"[bug]",
|
||||
"bug report",
|
||||
"bug",
|
||||
"issue",
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request",
|
||||
"[feature]",
|
||||
"[feature request]",
|
||||
"[enhancement]"
|
||||
]
|
||||
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (badTitles.includes(titleLower)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,72 @@ name: Build & Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
run: |
|
||||
# Check if this push is a merge commit from dev branch
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
BUMP="minor"
|
||||
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
|
||||
BUMP="minor"
|
||||
else
|
||||
BUMP="patch"
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(node -p "require('./server/package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
if [ "$BUMP" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update both package.json files
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -21,6 +82,8 @@ jobs:
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -54,13 +117,11 @@ jobs:
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -79,12 +140,13 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION=${{ needs.version-bump.outputs.version }}
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
docker buildx imagetools create \
|
||||
-t mauriceboe/trek:latest \
|
||||
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
|
||||
-t mauriceboe/trek:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
@@ -11,6 +11,9 @@ client/public/icons/*.png
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
@@ -28,6 +31,7 @@ Thumbs.db
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -52,3 +56,5 @@ coverage
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
@@ -0,0 +1,57 @@
|
||||
# Contributing to TREK
|
||||
|
||||
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
|
||||
## Pull Requests
|
||||
|
||||
### Your PR should include:
|
||||
|
||||
- **Summary** — What does this change and why? (1-3 bullet points)
|
||||
- **Test plan** — How did you verify it works?
|
||||
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
|
||||
|
||||
### Your PR will be closed if it:
|
||||
|
||||
- Wasn't discussed and approved in `#github-pr` on Discord first
|
||||
- Introduces breaking changes
|
||||
- Adds unnecessary complexity or features beyond scope
|
||||
- Reformats or refactors unrelated code
|
||||
- Adds dependencies without clear justification
|
||||
|
||||
### Commit messages
|
||||
|
||||
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
fix(maps): correct zoom level on Safari
|
||||
feat(budget): add CSV export for expenses
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
|
||||
# Server
|
||||
cd server && npm install && npm run dev
|
||||
|
||||
# Client (separate terminal)
|
||||
cd client && npm install && npm run dev
|
||||
```
|
||||
|
||||
Server: `http://localhost:3001` | Client: `http://localhost:5173`
|
||||
|
||||
On first run, check the server logs for the auto-generated admin credentials.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
+12
-17
@@ -1,4 +1,4 @@
|
||||
# Stage 1: React Client bauen
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
@@ -6,37 +6,32 @@ RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Produktions-Server
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
|
||||
# Server-Code kopieren
|
||||
COPY server/ ./
|
||||
|
||||
# Gebauten Client kopieren
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
|
||||
# Fonts für PDF-Export kopieren
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# MCP Integration
|
||||
|
||||
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||
structured API.
|
||||
|
||||
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
|
||||
1. Click **Create New Token**
|
||||
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
|
||||
### 3. Configure your MCP client
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer trek_your_token_here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
---
|
||||
|
||||
## Limitations & Important Notes
|
||||
|
||||
| Limitation | Details |
|
||||
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 API tokens per user. |
|
||||
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
|
||||
---
|
||||
|
||||
## Resources (read-only)
|
||||
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
| Resource | URI | Description |
|
||||
|-------------------|--------------------------------------------|-----------------------------------------------------------|
|
||||
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||
|
||||
---
|
||||
|
||||
## Tools (read-write)
|
||||
|
||||
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
|
||||
about a trip in a single call.
|
||||
|
||||
### Trip Summary
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
|
||||
|
||||
### Trips
|
||||
|
||||
| Tool | Description |
|
||||
|---------------|---------------------------------------------------------------------------------------------|
|
||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||
|
||||
### Places
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|-----------------------------------------------------------------------------------|
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
|
||||
| `update_place` | Update any field of an existing place. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|-------------------------------------------------------------------------------|
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||
| `unassign_place` | Remove a place assignment from a day. |
|
||||
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). |
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
|
||||
### Reservations
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|--------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||
| `delete_budget_item` | Remove a budget item. |
|
||||
|
||||
### Packing
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|--------------------------------------------------------------|
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item or change its category. |
|
||||
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||
| `delete_packing_item` | Remove a packing item. |
|
||||
|
||||
### Day Notes
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-----------------------------------------------------------------------|
|
||||
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||
| `delete_day_note` | Remove a note from a day. |
|
||||
|
||||
### Collab Notes
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||
| `delete_collab_note` | Delete a collab note and its associated files. |
|
||||
|
||||
### Bucket List
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------------------------------------------|
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||
|
||||
Initial prompt (1st message):
|
||||
|
||||
```
|
||||
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||
in mind when picking spots.
|
||||
|
||||
Before writing anything to TREK, do some research: look up what's worth
|
||||
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||
neighbourhood, and think about what kind of food and restaurant experiences
|
||||
are worth including.
|
||||
|
||||
Once you have a solid plan, write the whole thing to TREK:
|
||||
- Create the trip
|
||||
- Add all the places you've researched with their real coordinates
|
||||
- Build out the daily itinerary with sensible visiting times
|
||||
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||
- Add any notable restaurant reservations
|
||||
- Put together a realistic budget in EUR
|
||||
- Build a packing list suited to April in Kyoto
|
||||
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||
- Add a day note for each day with any important heads-up (early start, crowd
|
||||
tips, booking requirements, etc.)
|
||||
- Mark Japan as visited in my Atlas
|
||||
|
||||
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||
of everything that was added.
|
||||
```
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||
@@ -98,7 +99,9 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
@@ -120,20 +123,54 @@ services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME="SSO"
|
||||
# - OIDC_ONLY=true # disable password auth entirely
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -162,6 +199,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
@@ -226,17 +275,34 @@ trek.yourdomain.com {
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment | `production` |
|
||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
||||
| `OIDC_ISSUER` | OIDC provider URL | — |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
|
||||
| `OIDC_ONLY` | Disable password auth | `false` |
|
||||
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
||||
| `DEMO_MODE` | Enable demo mode | `false` |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial Setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
@@ -261,6 +327,7 @@ docker build -t trek .
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
|
||||
|
||||
+5
-2
@@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
@@ -29,5 +28,9 @@ See `values.yaml` for more options.
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- JWT_SECRET must be set for production use.
|
||||
- `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.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
|
||||
+20
-10
@@ -1,13 +1,23 @@
|
||||
1. JWT_SECRET handling:
|
||||
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||
1. ENCRYPTION_KEY handling:
|
||||
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
|
||||
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
|
||||
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
|
||||
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
|
||||
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||
auto-generate a key persisted to the data PVC.
|
||||
|
||||
2. Example usage:
|
||||
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||
- Generate a random secret: `--set generateJwtSecret=true`
|
||||
2. JWT_SECRET is managed entirely by the server:
|
||||
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
|
||||
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
|
||||
- No Helm configuration needed or supported.
|
||||
|
||||
3. Example usage:
|
||||
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
|
||||
- Generate a random key at install: `--set generateEncryptionKey=true`
|
||||
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
|
||||
|
||||
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,57 @@ metadata:
|
||||
data:
|
||||
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||
PORT: {{ .Values.env.PORT | quote }}
|
||||
{{- if .Values.env.TZ }}
|
||||
TZ: {{ .Values.env.TZ | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.APP_URL }}
|
||||
APP_URL: {{ .Values.env.APP_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_CLIENT_ID }}
|
||||
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISPLAY_NAME }}
|
||||
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ONLY }}
|
||||
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
|
||||
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_VALUE }}
|
||||
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_SCOPE }}
|
||||
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISCOVERY_URL }}
|
||||
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.DEMO_MODE }}
|
||||
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -11,6 +11,9 @@ spec:
|
||||
app: {{ include "trek.name" . }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
@@ -20,21 +23,46 @@ spec:
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
env:
|
||||
- name: JWT_SECRET
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
|
||||
optional: true
|
||||
- name: ADMIN_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: ADMIN_EMAIL
|
||||
optional: true
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: ADMIN_PASSWORD
|
||||
optional: true
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -10,6 +10,9 @@ metadata:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -23,3 +24,4 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -7,17 +7,41 @@ metadata:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
|
||||
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
|
||||
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
name: {{ $secretName }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||
{{- if and $existingSecret $existingSecret.data }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
|
||||
{{- else }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
+65
-9
@@ -15,21 +15,70 @@ service:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# TZ: "UTC"
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
# OIDC client ID.
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# OIDC_ADMIN_CLAIM: ""
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
# Value of the OIDC claim that grants admin role.
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
|
||||
|
||||
# JWT secret configuration
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
|
||||
# rotatable via the admin panel) — it is not configured here.
|
||||
secretEnv:
|
||||
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||
JWT_SECRET: ""
|
||||
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
|
||||
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
|
||||
# If left empty the server resolves the key automatically:
|
||||
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
|
||||
# 2. data/.encryption_key auto-generated on first start (fresh installs)
|
||||
ENCRYPTION_KEY: ""
|
||||
# Initial admin account — only used on first boot when no users exist yet.
|
||||
# If both values are non-empty the admin account is created with these credentials.
|
||||
# If either is empty a random password is generated and printed to the server log.
|
||||
ADMIN_EMAIL: ""
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
|
||||
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||
generateJwtSecret: false
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||
existingSecret: ""
|
||||
existingSecretKey: JWT_SECRET
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
@@ -38,10 +87,17 @@ persistence:
|
||||
uploads:
|
||||
size: 1Gi
|
||||
|
||||
resources: {}
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.3",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -2789,9 +2789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4679,9 +4679,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5941,9 +5941,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7181,9 +7181,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8705,9 +8705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.7.1",
|
||||
"version": "2.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+46
-7
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -12,10 +11,12 @@ import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -23,8 +24,12 @@ interface ProtectedRouteProps {
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -41,6 +46,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
@@ -63,17 +77,21 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
@@ -99,13 +117,26 @@ export default function App() {
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const location = useLocation()
|
||||
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)
|
||||
@@ -121,7 +152,7 @@ export default function App() {
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode])
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -187,6 +218,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InAppNotificationsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
|
||||
if (!url) return url
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resource-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ purpose }),
|
||||
})
|
||||
if (!resp.ok) return url
|
||||
const { token } = await resp.json()
|
||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||
|
||||
const MAX_CONCURRENT = 6
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function dequeue() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
active++
|
||||
queue.shift()!()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImageQueue() {
|
||||
queue.length = 0
|
||||
}
|
||||
|
||||
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||
if (!url) return ''
|
||||
return new Promise<string>((resolve) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) { resolve(''); return }
|
||||
const blob = await resp.blob()
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} catch {
|
||||
resolve('')
|
||||
} finally {
|
||||
active--
|
||||
dequeue()
|
||||
}
|
||||
}
|
||||
if (active < MAX_CONCURRENT) {
|
||||
active++
|
||||
run()
|
||||
} else {
|
||||
queue.push(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
+48
-12
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
// Request interceptor - add socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
@@ -28,12 +25,18 @@ apiClient.interceptors.request.use(
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token')
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -44,7 +47,7 @@ export const authApi = {
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
@@ -61,6 +64,11 @@ export const authApi = {
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
@@ -75,6 +83,7 @@ export const tripsApi = {
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
@@ -95,6 +104,8 @@ export const placesApi = {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -151,7 +162,6 @@ export const adminApi = {
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -170,6 +180,13 @@ export const adminApi = {
|
||||
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),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -267,9 +284,8 @@ export const backupApi = {
|
||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||
download: async (filename: string): Promise<void> => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
@@ -302,6 +318,26 @@ export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
+42
-12
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
let currentToken: string | null = null
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token: string): string {
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
}
|
||||
|
||||
async function fetchWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/ws-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (resp.status === 401) {
|
||||
// Session expired — stop reconnecting
|
||||
shouldReconnect = false
|
||||
return null
|
||||
}
|
||||
if (!resp.ok) return null
|
||||
const { token } = await resp.json()
|
||||
return token as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent): void {
|
||||
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (currentToken) {
|
||||
connectInternal(currentToken, true)
|
||||
if (shouldReconnect) {
|
||||
connectInternal(true)
|
||||
}
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token: string, _isReconnect = false): void {
|
||||
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
if (connecting) return
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(token)
|
||||
connecting = true
|
||||
const wsToken = await fetchWsToken()
|
||||
connecting = false
|
||||
|
||||
if (!wsToken) {
|
||||
if (shouldReconnect) scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(wsToken)
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
if (currentToken) {
|
||||
if (shouldReconnect) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token: string): void {
|
||||
currentToken = token
|
||||
export function connect(): void {
|
||||
shouldReconnect = true
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connectInternal(token, false)
|
||||
connectInternal(false)
|
||||
}
|
||||
|
||||
export function disconnect(): void {
|
||||
currentToken = null
|
||||
shouldReconnect = false
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
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 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -32,6 +33,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -57,7 +59,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
window.dispatchEvent(new Event('addons-changed'))
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
@@ -68,6 +70,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -144,6 +147,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -188,11 +206,8 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
user_id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setDeleteConfirmId(null)
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.mcpTokens.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,11 @@ interface AuditEntry {
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export default function AuditLogPanel(): React.ReactElement {
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(locale, {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
|
||||
@@ -324,9 +324,11 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AppUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
|
||||
const [users, setUsers] = useState<AppUser[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.list().then(data => {
|
||||
const list = (data.trips || data || []) as Trip[]
|
||||
setTrips(list)
|
||||
if (list.length > 0) setSelectedTripId(list[0].id)
|
||||
}).catch(() => {})
|
||||
adminApi.users().then(data => {
|
||||
const list = (data.users || data || []) as AppUser[]
|
||||
setUsers(list)
|
||||
if (list.length > 0) setSelectedUserId(list[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const send = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: 'Simple → Me',
|
||||
icon: Bell,
|
||||
color: '#6366f1',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Boolean → Me',
|
||||
icon: CheckCircle,
|
||||
color: '#10b981',
|
||||
payload: {
|
||||
type: 'boolean',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Navigate → Me',
|
||||
icon: Navigation,
|
||||
color: '#f59e0b',
|
||||
payload: {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Simple → Admins',
|
||||
icon: Zap,
|
||||
color: '#ef4444',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
title_key: 'notifications.test.adminTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.adminText',
|
||||
text_params: { actor: user?.username || 'Admin' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
||||
</p>
|
||||
|
||||
{/* Quick-fire buttons */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{buttons.map(btn => {
|
||||
const Icon = btn.icon
|
||||
return (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => send(btn.label, btn.payload)}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${btn.color}20`, color: btn.color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
||||
{btn.payload.type} · {btn.payload.scope}
|
||||
</p>
|
||||
</div>
|
||||
{sending === btn.label && (
|
||||
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip-scoped notifications */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={trip.id}>{trip.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Simple → Trip', {
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
|
||||
<Send className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Navigate → Trip', {
|
||||
type: 'navigate',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: `/trips/${selectedTripId}`,
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-scoped notifications */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#06b6d420', color: '#06b6d4' }}>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'boolean',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#10b98120', color: '#10b981' }}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -115,7 +119,7 @@ export default function GitHubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
@@ -152,6 +156,24 @@ export default function GitHubPanel() {
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string
|
||||
level: PermissionLevel
|
||||
defaultLevel: PermissionLevel
|
||||
allowedLevels: PermissionLevel[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
admin: 'perm.level.admin',
|
||||
trip_owner: 'perm.level.tripOwner',
|
||||
trip_member: 'perm.level.tripMember',
|
||||
everybody: 'perm.level.everybody',
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
]
|
||||
|
||||
export default function PermissionsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions()
|
||||
}, [])
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getPermissions()
|
||||
setEntries(data.permissions)
|
||||
const vals: Record<string, PermissionLevel> = {}
|
||||
for (const p of data.permissions) vals[p.key] = p.level
|
||||
setValues(vals)
|
||||
setDirty(false)
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key: string, level: PermissionLevel) => {
|
||||
setValues(prev => ({ ...prev, [key]: level }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await adminApi.updatePermissions(values)
|
||||
if (data.permissions) {
|
||||
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||
}
|
||||
setDirty(false)
|
||||
toast.success(t('perm.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults: Record<string, PermissionLevel> = {}
|
||||
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||
setValues(defaults)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
@@ -59,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
|
||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||
|
||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value ?? '')
|
||||
const inputRef = useRef(null)
|
||||
@@ -86,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
<div onClick={() => { 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 }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
@@ -99,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -109,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
@@ -132,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
@@ -227,9 +235,10 @@ interface BudgetMemberChipsProps {
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
@@ -271,17 +280,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
@@ -412,12 +423,14 @@ interface BudgetPanelProps {
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
@@ -470,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped[cat] || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
@@ -482,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => 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 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => 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 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -504,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
@@ -518,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{editingCat?.name === cat ? (
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
@@ -530,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -552,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -574,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
@@ -584,12 +643,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
@@ -598,29 +658,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -629,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
@@ -666,7 +741,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300) {
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
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',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -94,22 +97,33 @@ interface FilePreviewPortalProps {
|
||||
}
|
||||
|
||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
if (!rawUrl) return
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
if (!file) return null
|
||||
const url = file.url || `/uploads/${file.filename}`
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = async () => {
|
||||
const u = await getAuthUrl(rawUrl, 'download')
|
||||
window.open(u, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
<img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
|
||||
const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
@@ -216,7 +238,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
@@ -226,6 +248,9 @@ interface NoteFormModalProps {
|
||||
}
|
||||
|
||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
const isEdit = !!note
|
||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||
|
||||
@@ -298,6 +323,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -450,11 +476,11 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
<div>
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
@@ -478,12 +504,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label htmlFor="note-file-input"
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
@@ -689,6 +715,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
@@ -699,7 +726,7 @@ interface NoteCardProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
@@ -760,24 +787,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>
|
||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
@@ -838,7 +865,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<img key={a.id} src={a.url} alt={a.original_name}
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
@@ -879,6 +906,9 @@ interface CollabNotesProps {
|
||||
|
||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [notes, setNotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
@@ -964,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||
}
|
||||
// Reload note with attachments
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
@@ -1124,17 +1154,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button onClick={() => setShowNewModal(true)}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1252,6 +1282,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
key={note.id}
|
||||
note={note}
|
||||
currentUser={currentUser}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleUpdateNote}
|
||||
onDelete={handleDeleteNote}
|
||||
onEdit={setEditingNote}
|
||||
@@ -1303,12 +1334,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</button>}
|
||||
<button onClick={() => setViewingNote(null)}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -1327,6 +1358,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
|
||||
{showNewModal && (
|
||||
<NoteFormModal
|
||||
note={null}
|
||||
tripId={tripId}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSubmit={handleCreateNote}
|
||||
existingCategories={categories}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
|
||||
@@ -4,11 +4,17 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -41,6 +45,10 @@ interface ImageLightboxProps {
|
||||
|
||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file.url])
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
@@ -48,16 +56,20 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={file.url}
|
||||
src={imgSrc}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}
|
||||
title={t('files.openTab')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -68,6 +80,15 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated image — fetches a short-lived download token and renders the image
|
||||
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||
}
|
||||
|
||||
// Source badge
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
@@ -153,6 +174,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const loadTrash = useCallback(async () => {
|
||||
@@ -247,6 +270,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
})
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (!can('file_upload', trip)) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const pastedFiles = []
|
||||
@@ -281,6 +305,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||
} else {
|
||||
setPreviewFileUrl('')
|
||||
}
|
||||
}, [previewFile?.url])
|
||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||
|
||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||
@@ -311,8 +343,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
@@ -326,7 +356,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -334,7 +364,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}}
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
@@ -355,7 +385,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
)}
|
||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||
<span
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
>
|
||||
{file.original_name}
|
||||
@@ -386,14 +416,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -401,18 +431,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -622,12 +652,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</a>
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -637,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
@@ -675,7 +706,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{showTrash ? (
|
||||
/* Trash view */
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && (
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
@@ -704,7 +735,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
) : (
|
||||
<>
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
@@ -729,7 +760,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!open) {
|
||||
fetchNotifications(true)
|
||||
}
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
const handleShowAll = () => {
|
||||
setOpen(false)
|
||||
navigate('/notifications')
|
||||
}
|
||||
|
||||
const displayCount = unreadCount > 99 ? '99+' : unreadCount
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
title={t('notifications.title')}
|
||||
className="relative p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
fontSize: 9,
|
||||
minWidth: 14,
|
||||
height: 14,
|
||||
padding: '0 3px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
right: 8,
|
||||
width: 360,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border-secondary)' }}
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: '#6366f1', color: '#fff' }}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: '#6366f1',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import ReactDOM from 'react-dom'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi } from '../../api/client'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
|
||||
@@ -28,29 +29,21 @@ interface Addon {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const loadAddons = () => {
|
||||
if (user) {
|
||||
addonsApi.enabled().then(data => {
|
||||
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
useEffect(loadAddons, [user, location.pathname])
|
||||
// Listen for addon changes from AddonManager
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => loadAddons()
|
||||
window.addEventListener('addons-changed', handler)
|
||||
return () => window.removeEventListener('addons-changed', handler)
|
||||
}, [user])
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
@@ -171,6 +164,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Notification bell */}
|
||||
{user && <InAppNotificationBell />}
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
<div className="relative">
|
||||
@@ -236,9 +232,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
@@ -26,7 +34,12 @@ function escAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
const borderWidth = isSelected ? 3 : 2.5
|
||||
@@ -34,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
const icon = place.category_icon || '📍'
|
||||
|
||||
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
||||
// Number badges (bottom-right)
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
if (place.image_url) {
|
||||
return L.divIcon({
|
||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:visible;background:${bgColor};
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
width:${size}px;height:${size}px;
|
||||
cursor:pointer;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
">
|
||||
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, imgIcon)
|
||||
return imgIcon
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
const fallbackIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;position:relative;
|
||||
will-change:transform;contain:layout style;
|
||||
">
|
||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
||||
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, fallbackIcon)
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
@@ -166,6 +187,16 @@ interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
map.on('zoomstart', onZoomStart)
|
||||
map.on('zoomend', onZoomEnd)
|
||||
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||
}, [map, onZoomStart, onZoomEnd])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
@@ -237,8 +268,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
const mapPhotoInFlight = new Set()
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
@@ -330,7 +360,7 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
@@ -358,54 +388,110 @@ export function MapView({
|
||||
const right = rightWidth + 40
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector])
|
||||
const [photoUrls, setPhotoUrls] = useState({})
|
||||
|
||||
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
const queue = places.filter(place => {
|
||||
if (place.image_url) return false
|
||||
if (!places || places.length === 0) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) return false
|
||||
if (mapPhotoCache.has(cacheKey)) {
|
||||
const cached = mapPhotoCache.get(cacheKey)
|
||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||
return false
|
||||
if (!cacheKey) continue
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
if (mapPhotoInFlight.has(cacheKey)) return false
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
let active = 0
|
||||
const MAX_CONCURRENT = 3
|
||||
let idx = 0
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
const fetchNext = () => {
|
||||
while (active < MAX_CONCURRENT && idx < queue.length) {
|
||||
const place = queue[idx++]
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
// Always fetch through API — returns fresh URL + converts to base64
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
mapPhotoInFlight.add(cacheKey)
|
||||
active++
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||
} else {
|
||||
mapPhotoCache.set(cacheKey, null)
|
||||
}
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
||||
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchNext()
|
||||
}, [places])
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}, [placeIds])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
zoomControl={false}
|
||||
@@ -416,6 +502,10 @@ export function MapView({
|
||||
url={tileUrl}
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
@@ -427,71 +517,17 @@ export function MapView({
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
@@ -508,6 +544,24 @@ export function MapView({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => {
|
||||
let revoke = ''
|
||||
fetchImageAsBlob(baseUrl).then(blobUrl => {
|
||||
revoke = blobUrl
|
||||
setSrc(blobUrl)
|
||||
})
|
||||
return () => { if (revoke) URL.revokeObjectURL(revoke) }
|
||||
}, [baseUrl])
|
||||
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +46,7 @@ interface MemoriesPanelProps {
|
||||
|
||||
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
@@ -52,11 +68,65 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [locationFilter, setLocationFilter] = useState('')
|
||||
|
||||
// Album linking
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
|
||||
const loadAlbumLinks = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||
setAlbumLinks(res.data.links || [])
|
||||
} catch { setAlbumLinks([]) }
|
||||
}
|
||||
|
||||
const openAlbumPicker = async () => {
|
||||
setShowAlbumPicker(true)
|
||||
setAlbumsLoading(true)
|
||||
try {
|
||||
const res = await apiClient.get('/integrations/immich/albums')
|
||||
setAlbums(res.data.albums || [])
|
||||
} catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
|
||||
finally { setAlbumsLoading(false) }
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
// Auto-sync after linking
|
||||
const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
||||
if (newLink) await syncAlbum(newLink.id)
|
||||
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||
}
|
||||
|
||||
const unlinkAlbum = async (linkId: number) => {
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
||||
loadAlbumLinks()
|
||||
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||
}
|
||||
|
||||
const syncAlbum = async (linkId: number) => {
|
||||
setSyncing(linkId)
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
|
||||
await loadAlbumLinks()
|
||||
await loadPhotos()
|
||||
} catch { toast.error(t('memories.error.syncAlbum')) }
|
||||
finally { setSyncing(null) }
|
||||
}
|
||||
|
||||
// Lightbox
|
||||
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,6 +159,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
setConnected(false)
|
||||
}
|
||||
await loadPhotos()
|
||||
await loadAlbumLinks()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -114,6 +185,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
setPickerPhotos(res.data.assets || [])
|
||||
} catch {
|
||||
setPickerPhotos([])
|
||||
toast.error(t('memories.error.loadPhotos'))
|
||||
} finally {
|
||||
setPickerLoading(false)
|
||||
}
|
||||
@@ -141,8 +213,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
shared: true,
|
||||
})
|
||||
setShowPicker(false)
|
||||
clearImageQueue()
|
||||
loadInitial()
|
||||
} catch {}
|
||||
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||
}
|
||||
|
||||
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||
@@ -151,7 +224,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||
} catch {}
|
||||
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||
}
|
||||
|
||||
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||
@@ -162,18 +235,13 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch {}
|
||||
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const token = useAuthStore(s => s.token)
|
||||
|
||||
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||
|
||||
const originalUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||
|
||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||
@@ -224,6 +292,72 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
// ── Album Picker Modal ──────────────────────────────────────────────────
|
||||
|
||||
if (showAlbumPicker) {
|
||||
const linkedIds = new Set(albumLinks.map(l => l.immich_album_id))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.selectAlbum')}
|
||||
</h3>
|
||||
<button onClick={() => setShowAlbumPicker(false)}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{albumsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<div style={{ width: 24, height: 24, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||
</div>
|
||||
) : albums.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: 40, fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{t('memories.noAlbums')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
borderRadius: 10, border: 'none', cursor: isLinked ? 'default' : 'pointer',
|
||||
background: isLinked ? 'var(--bg-tertiary)' : 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
opacity: isLinked ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLinked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isLinked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<FolderOpen size={20} color="var(--text-muted)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{album.albumName}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{album.assetCount} {t('memories.photos')}
|
||||
</div>
|
||||
</div>
|
||||
{isLinked ? (
|
||||
<Check size={16} color="var(--text-faint)" />
|
||||
) : (
|
||||
<Link2 size={16} color="var(--text-muted)" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
if (showPicker) {
|
||||
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||
|
||||
@@ -237,7 +371,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{t('memories.selectPhotos')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
<button onClick={() => { clearImageQueue(); setShowPicker(false) }}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
@@ -328,7 +462,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||
outlineOffset: -3,
|
||||
}}>
|
||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
@@ -404,16 +538,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</p>
|
||||
</div>
|
||||
{connected && (
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={14} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={openAlbumPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={13} /> {t('memories.linkAlbum')}
|
||||
</button>
|
||||
<button onClick={openPicker}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={14} /> {t('memories.addPhotos')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Linked Albums */}
|
||||
{albumLinks.length > 0 && (
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{albumLinks.map(link => (
|
||||
<div key={link.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', fontSize: 11, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderOpen size={11} />
|
||||
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
|
||||
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
|
||||
<button onClick={() => syncAlbum(link.id)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
|
||||
</button>
|
||||
{link.user_id === currentUser?.id && (
|
||||
<button onClick={() => unlinkAlbum(link.id)} title={t('memories.unlinkAlbum')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter & Sort bar */}
|
||||
@@ -470,12 +640,15 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc)
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
}}>
|
||||
|
||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||
|
||||
{/* Other user's avatar */}
|
||||
@@ -577,12 +750,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxId && lightboxUserId && (
|
||||
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||
@@ -592,7 +765,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</button>
|
||||
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||
<img
|
||||
src={originalUrl(lightboxId, lightboxUserId)}
|
||||
src={lightboxOriginalSrc}
|
||||
alt=""
|
||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const [responding, setResponding] = useState(false)
|
||||
|
||||
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!notification.is_read) await markRead(notification.id)
|
||||
if (notification.navigate_target) {
|
||||
navigate(notification.navigate_target)
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRespond = async (response: 'positive' | 'negative') => {
|
||||
if (responding || notification.response !== null) return
|
||||
setResponding(true)
|
||||
await respondToBoolean(notification.id, response)
|
||||
setResponding(false)
|
||||
}
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
style={{
|
||||
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
{!notification.is_read && (
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full" style={{ background: '#6366f1' }} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? '#6366f1'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'negative' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'positive' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
@@ -77,9 +78,10 @@ interface ArtikelZeileProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
@@ -130,7 +132,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
{editing && canEdit ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => !item.checked && setEditing(true)}
|
||||
onClick={() => canEdit && !item.checked && setEditing(true)}
|
||||
style={{
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: item.checked ? 'default' : 'text',
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
@@ -159,7 +161,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onChange={async e => {
|
||||
if (!canEdit) return
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '')
|
||||
const v = raw === '' ? null : parseInt(raw)
|
||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
|
||||
@@ -171,9 +175,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowBagPicker(p => !p)}
|
||||
onClick={() => canEdit && setShowBagPicker(p => !p)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
||||
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
|
||||
}}
|
||||
@@ -247,6 +251,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -287,6 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
@@ -380,7 +387,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName ? (
|
||||
{editingName && canEdit ? (
|
||||
<input
|
||||
autoFocus value={editKatName}
|
||||
onChange={e => setEditKatName(e.target.value)}
|
||||
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||
{assignees.map(a => (
|
||||
<div key={a.user_id} style={{ position: 'relative' }}
|
||||
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
>
|
||||
<div className="assignee-chip"
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
@@ -422,6 +429,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||
style={{
|
||||
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
@@ -497,11 +506,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{showAddItem ? (
|
||||
{canEdit && (showAddItem ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||
<input
|
||||
ref={addItemRef}
|
||||
@@ -548,7 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Plus size={12} /> {t('packing.addItem')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -589,6 +600,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -814,7 +828,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{abgehakt > 0 && (
|
||||
{canEdit && abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -823,6 +837,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
{availableTemplates.length > 0 && (
|
||||
)}
|
||||
{canEdit && availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
@@ -899,7 +915,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingCategory ? (
|
||||
{canEdit && (addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -924,7 +940,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
bags={bags}
|
||||
onCreateBag={handleCreateBagByName}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||
@@ -1054,16 +1073,16 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
|
||||
<Plus size={11} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
||||
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||
@@ -1142,7 +1163,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Plus size={14} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canEditDays = can('day_edit', tripObj)
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
@@ -111,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setAccommodation(data.accommodation)
|
||||
setAccommodations(prev => [...prev, data.accommodation])
|
||||
const newAcc = data.accommodation
|
||||
const updated = [...accommodations, newAcc]
|
||||
setAccommodations(updated)
|
||||
setAccommodation(newAcc)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
@@ -132,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
if (!accommodation) return
|
||||
try {
|
||||
await accommodationsApi.delete(tripId, accommodation.id)
|
||||
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||
setAccommodations(updated)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setAccommodation(null)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
@@ -140,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
|
||||
getLocaleForLanguage(language),
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||
) : null
|
||||
|
||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||
@@ -328,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
</button>}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{/* Details grid */}
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
@@ -385,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
})}
|
||||
{/* Add another hotel */}
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button> : null
|
||||
)}
|
||||
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
@@ -431,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -443,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -549,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
setAccommodations(d.accommodations || [])
|
||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
const all = d.accommodations || []
|
||||
setAccommodations(all)
|
||||
setDayAccommodations(all.filter(a =>
|
||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
||||
))
|
||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -12,10 +12,13 @@ import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
@@ -76,9 +79,14 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
@@ -88,12 +96,19 @@ export default function DayPlanSidebar({
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onNavigateToFiles,
|
||||
onExpandedDaysChange,
|
||||
pushUndo,
|
||||
canUndo = false,
|
||||
lastActionLabel = null,
|
||||
onUndo,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
@@ -104,6 +119,7 @@ export default function DayPlanSidebar({
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
})
|
||||
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
@@ -111,6 +127,9 @@ export default function DayPlanSidebar({
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
@@ -323,6 +342,16 @@ export default function DayPlanSidebar({
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const mergedItemsMap = useMemo(() => {
|
||||
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
|
||||
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
|
||||
return map
|
||||
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, assignments, dayNotes, reservations])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
@@ -377,6 +406,9 @@ export default function DayPlanSidebar({
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
// Capture previous place order for undo
|
||||
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
|
||||
|
||||
// Places get sequential integer positions (0, 1, 2, ...)
|
||||
// Non-place items between place N-1 and place N get fractional positions
|
||||
const assignmentIds: number[] = []
|
||||
@@ -410,7 +442,7 @@ export default function DayPlanSidebar({
|
||||
try {
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
for (const n of noteUpdates) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
@@ -419,6 +451,13 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates)
|
||||
}
|
||||
if (prevAssignmentIds.length) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
pushUndo?.(t('undo.reorder'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
@@ -503,7 +542,7 @@ export default function DayPlanSidebar({
|
||||
currentAssignments[key] = currentAssignments[key].map(a =>
|
||||
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
||||
)
|
||||
tripStore.setAssignments(currentAssignments)
|
||||
tripActions.setAssignments(currentAssignments)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
@@ -581,12 +620,14 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
|
||||
const toggleLock = (assignmentId) => {
|
||||
const prevLocked = new Set(lockedIds)
|
||||
setLockedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(assignmentId)) next.delete(assignmentId)
|
||||
else next.add(assignmentId)
|
||||
return next
|
||||
})
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
@@ -594,6 +635,8 @@ export default function DayPlanSidebar({
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
|
||||
// Separate locked (stay at their index) and unlocked assignments
|
||||
const locked = new Map() // index -> assignment
|
||||
const unlocked = []
|
||||
@@ -620,6 +663,10 @@ export default function DayPlanSidebar({
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
toast.success(t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
}
|
||||
|
||||
const handleGoogleMaps = () => {
|
||||
@@ -632,14 +679,24 @@ export default function DayPlanSidebar({
|
||||
|
||||
const handleDropOnDay = (e, dayId) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
|
||||
const capturedFromDayId = fromDayId
|
||||
const capturedOrderIndex = srcAssignment?.order_index ?? 0
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
|
||||
.then(() => {
|
||||
pushUndo?.(t('undo.moveDay'), async () => {
|
||||
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -668,10 +725,10 @@ export default function DayPlanSidebar({
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const totalCost = days.reduce((s, d) => {
|
||||
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
}, 0), [days, assignments])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
@@ -686,62 +743,124 @@ export default function DayPlanSidebar({
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')}
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
title={t('dayplan.pdfTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
}}
|
||||
title={t('dayplan.icsTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPdfHover(true)}
|
||||
onMouseLeave={() => setPdfHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
{pdfHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.pdfTooltip')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.icsTooltip')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onUndo && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: canUndo ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
cursor: canUndo ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{undoHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -755,7 +874,7 @@ export default function DayPlanSidebar({
|
||||
const formattedDate = formatDate(day.date, locale)
|
||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||
const isDragTarget = dragOverDayId === day.id
|
||||
const merged = getMergedItems(day.id)
|
||||
const merged = mergedItemsMap[day.id] || []
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
@@ -777,6 +896,7 @@ export default function DayPlanSidebar({
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
outlineOffset: -2,
|
||||
borderRadius: isDragTarget ? 8 : 0,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
@@ -810,15 +930,15 @@ export default function DayPlanSidebar({
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
@@ -862,20 +982,20 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
title={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
|
||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -886,6 +1006,7 @@ export default function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
@@ -894,11 +1015,11 @@ export default function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
||||
}
|
||||
@@ -912,11 +1033,11 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -992,8 +1113,9 @@ export default function DayPlanSidebar({
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -1010,7 +1132,7 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -1018,7 +1140,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -1027,12 +1149,12 @@ export default function DayPlanSidebar({
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
@@ -1050,9 +1172,9 @@ export default function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div
|
||||
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||
@@ -1103,10 +1225,8 @@ export default function DayPlanSidebar({
|
||||
)}
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
@@ -1155,14 +1275,14 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1199,11 +1319,11 @@ export default function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
||||
}
|
||||
@@ -1261,8 +1381,8 @@ export default function DayPlanSidebar({
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -1272,7 +1392,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -1280,17 +1400,17 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
])}
|
||||
]) : undefined}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -1304,9 +1424,9 @@ export default function DayPlanSidebar({
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||
</div>
|
||||
@@ -1315,17 +1435,17 @@ export default function DayPlanSidebar({
|
||||
{note.text}
|
||||
</span>
|
||||
{note.time && (
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1345,11 +1465,11 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1406,7 +1526,7 @@ export default function DayPlanSidebar({
|
||||
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
||||
<div key={dayId} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => cancelNote(Number(dayId))}>
|
||||
@@ -1423,8 +1543,8 @@ export default function DayPlanSidebar({
|
||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
||||
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
||||
title={id}
|
||||
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1434,8 +1554,9 @@ export default function DayPlanSidebar({
|
||||
value={ui.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
placeholder={t('dayplan.noteTitle') + ' *'}
|
||||
required
|
||||
style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
@@ -1446,10 +1567,10 @@ export default function DayPlanSidebar({
|
||||
placeholder={t('dayplan.noteSubtitle')}
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} style={{ fontSize: 12, background: !ui.text?.trim() ? 'var(--border-primary)' : 'var(--accent)', color: !ui.text?.trim() ? 'var(--text-faint)' : 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1550,7 +1671,7 @@ export default function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
@@ -1594,13 +1715,13 @@ export default function DayPlanSidebar({
|
||||
{res.notes && (
|
||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
||||
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dateien */}
|
||||
{(() => {
|
||||
const resFiles = (tripStore.files || []).filter(f =>
|
||||
const resFiles = (useTripStore.getState().files || []).filter(f =>
|
||||
!f.deleted_at && (
|
||||
f.reservation_id === res.id ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||
@@ -1661,4 +1782,6 @@ export default function DayPlanSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default DayPlanSidebar
|
||||
|
||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { hasMapsKey } = useAuthStore()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
|
||||
|
||||
// Paste support for files/images
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
|
||||
</div>
|
||||
|
||||
{/* File Attachments */}
|
||||
{true && (
|
||||
{canUploadFiles && (
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -116,7 +119,7 @@ interface PlaceInspectorProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
@@ -339,10 +342,8 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || place.notes || googleDetails?.summary) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
{place.description || place.notes || googleDetails?.summary}
|
||||
</p>
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,7 +373,7 @@ export default function PlaceInspector({
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
@@ -391,7 +392,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
@@ -461,6 +462,98 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
|
||||
{/* GPX Track stats */}
|
||||
{place.route_geometry && (() => {
|
||||
try {
|
||||
const pts: number[][] = JSON.parse(place.route_geometry)
|
||||
if (!pts || pts.length < 2) return null
|
||||
const hasEle = pts[0].length >= 3
|
||||
|
||||
// Haversine distance
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
let totalDist = 0
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
|
||||
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
|
||||
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
const distKm = totalDist / 1000
|
||||
|
||||
// Elevation stats
|
||||
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
|
||||
if (hasEle) {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const e = pts[i][2]
|
||||
if (e < minEle) minEle = e
|
||||
if (e > maxEle) maxEle = e
|
||||
if (i > 0) {
|
||||
const diff = e - pts[i - 1][2]
|
||||
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elevation profile SVG
|
||||
const chartW = 280, chartH = 60
|
||||
const elevations = hasEle ? pts.map(p => p[2]) : []
|
||||
let pathD = ''
|
||||
if (elevations.length > 1) {
|
||||
const step = Math.max(1, Math.floor(elevations.length / chartW))
|
||||
const sampled = elevations.filter((_, i) => i % step === 0)
|
||||
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
|
||||
const range = eMax - eMin || 1
|
||||
pathD = sampled.map((e, i) => {
|
||||
const x = (i / (sampled.length - 1)) * chartW
|
||||
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendingUp size={13} color="#9ca3af" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<MapPin size={12} color="#3b82f6" />
|
||||
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
||||
</div>
|
||||
{hasEle && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#22c55e" />
|
||||
{Math.round(maxEle)} m
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#ef4444" />
|
||||
{Math.round(minEle)} m
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{pathD && (
|
||||
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||
<defs>
|
||||
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
|
||||
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch { return null }
|
||||
})()}
|
||||
|
||||
{/* Files section */}
|
||||
{(placeFiles.length > 0 || onFileUpload) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
@@ -489,11 +582,11 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
@@ -28,17 +29,21 @@ interface PlacesSidebarProps {
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const tripStore = useTripStore()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -46,12 +51,51 @@ export default function PlacesSidebar({
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await tripStore.loadTrip(tripId)
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
}
|
||||
|
||||
const [googleListOpen, setGoogleListOpen] = useState(false)
|
||||
const [googleListUrl, setGoogleListUrl] = useState('')
|
||||
const [googleListLoading, setGoogleListLoading] = useState(false)
|
||||
|
||||
const handleGoogleListImport = async () => {
|
||||
if (!googleListUrl.trim()) return
|
||||
setGoogleListLoading(true)
|
||||
try {
|
||||
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
||||
setGoogleListOpen(false)
|
||||
setGoogleListUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGoogleList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
||||
} finally {
|
||||
setGoogleListLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
@@ -67,11 +111,12 @@ export default function PlacesSidebar({
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
@@ -79,7 +124,7 @@ export default function PlacesSidebar({
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||
}), [places, filter, categoryFilters, search, plannedIds])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
@@ -88,7 +133,7 @@ export default function PlacesSidebar({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Kopfbereich */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<button
|
||||
{canEditPlaces && <button
|
||||
onClick={onAddPlace}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
@@ -98,20 +143,36 @@ export default function PlacesSidebar({
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
{canEditPlaces && <>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGoogleListOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
@@ -223,9 +284,9 @@ export default function PlacesSidebar({
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
@@ -245,19 +306,19 @@ export default function PlacesSidebar({
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile && days?.length > 0) {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -312,49 +373,133 @@ export default function PlacesSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
||||
{dayPickerPlace && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => setDayPickerPlace(null)}
|
||||
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
||||
{dayPickerPlace.address && <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{dayPickerPlace.address}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
return (
|
||||
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
|
||||
{/* View details */}
|
||||
<button
|
||||
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Eye size={18} color="var(--text-muted)" /> {t('places.viewDetails')}
|
||||
</button>
|
||||
{/* Edit */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Pencil size={18} color="var(--text-muted)" /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{/* Assign to day */}
|
||||
{days?.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => setMobileShowDays(v => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
||||
}}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
|
||||
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{mobileShowDays && (
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Delete */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: '#ef4444' }}
|
||||
>
|
||||
<Trash2 size={18} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{googleListOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
{t('places.importGoogleList')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||
{t('places.googleListHint')}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={googleListUrl}
|
||||
onChange={e => setGoogleListUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
||||
placeholder="https://maps.app.goo.gl/..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
|
||||
fontSize: 13, color: 'var(--text-primary)', outline: 'none',
|
||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoogleListImport}
|
||||
disabled={!googleListUrl.trim() || googleListLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{googleListLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -363,4 +508,6 @@ export default function PlacesSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default PlacesSidebar
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ReservationModalProps {
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
}
|
||||
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -572,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -56,9 +57,10 @@ interface ReservationCardProps {
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -82,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
@@ -131,7 +143,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -330,6 +342,9 @@ interface ReservationsPanelProps {
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('reservation_edit', trip)
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
@@ -348,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
||||
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -23,13 +24,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
|
||||
const can = useCanDo()
|
||||
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
@@ -41,25 +49,40 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
useEffect(() => {
|
||||
if (trip) {
|
||||
const rd = trip.reminder_days ?? 3
|
||||
setFormData({
|
||||
title: trip.title || '',
|
||||
description: trip.description || '',
|
||||
start_date: trip.start_date || '',
|
||||
end_date: trip.end_date || '',
|
||||
reminder_days: rd,
|
||||
})
|
||||
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||
setCoverPreview(trip.cover_image || null)
|
||||
} else {
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
|
||||
setCustomReminder(false)
|
||||
setCoverPreview(null)
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setSelectedMembers([])
|
||||
setError('')
|
||||
if (isOpen) {
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (!trip) {
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip && isOpen) {
|
||||
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||
}
|
||||
}, [tripRemindersEnabled])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -74,6 +97,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
description: formData.description.trim() || null,
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
})
|
||||
// Add selected members for newly created trips
|
||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||
@@ -154,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
// Paste support for cover image
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadCover) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -172,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
const newEnd = new Date(value + 'T00:00:00')
|
||||
const newEnd = new Date(value + 'T00:00:00Z')
|
||||
newEnd.setDate(newEnd.getDate() + duration)
|
||||
next.end_date = newEnd.toISOString().split('T')[0]
|
||||
}
|
||||
@@ -211,8 +236,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Cover image — available for both create and edit */}
|
||||
<div>
|
||||
{/* Cover image — gated by trip_cover_upload permission */}
|
||||
{canUploadCover && <div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||
{coverPreview ? (
|
||||
@@ -240,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
|
||||
@@ -272,6 +297,59 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reminder — only visible to owner (or when creating) */}
|
||||
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
|
||||
</label>
|
||||
{!tripRemindersEnabled ? (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('trips.reminderDisabledHint')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 0, label: t('trips.reminderNone') },
|
||||
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
|
||||
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
|
||||
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
|
||||
].map(opt => (
|
||||
<button key={opt.value} type="button"
|
||||
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
!customReminder && formData.reminder_days === opt.value
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
customReminder
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{t('trips.reminderCustom')}
|
||||
</button>
|
||||
</div>
|
||||
{customReminder && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="number" min={1} max={30}
|
||||
value={formData.reminder_days}
|
||||
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members — only for new trips */}
|
||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
<div>
|
||||
@@ -312,11 +390,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formData.start_date && !formData.end_date && (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('dashboard.noDateHint')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
const toast = useToast()
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canManageMembers = can('member_manage', trip)
|
||||
const canManageShare = can('share_manage', trip)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && tripId) {
|
||||
@@ -247,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
<div>
|
||||
{canManageMembers && <div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && (
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{allMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function VacayMonthCard({
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
|
||||
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||
style={{
|
||||
height: 28,
|
||||
|
||||
@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return weekendDays.includes(d.getDay())
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return weekendDays.includes(d.getUTCDay())
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, locale?: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
|
||||
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
borderless?: boolean
|
||||
}
|
||||
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
const parsed = value ? new Date(value + 'T00:00:00Z') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
@@ -55,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
@@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
) : (
|
||||
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
|
||||
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
|
||||
border: borderless ? 'none' : '1px solid var(--border-primary)',
|
||||
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CustomSelectProps {
|
||||
searchable?: boolean
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
@@ -29,6 +30,7 @@ export default function CustomSelect({
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -83,17 +85,19 @@ export default function CustomSelect({
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
|
||||
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
onChange(raw)
|
||||
if (is12h) return // let handleBlur parse 12h formats
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||
@@ -80,7 +81,23 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value) return
|
||||
const clean = value.replace(/[^0-9:]/g, '')
|
||||
const raw = value.trim()
|
||||
|
||||
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
|
||||
if (is12h) {
|
||||
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
|
||||
if (match12) {
|
||||
let h = parseInt(match12[1])
|
||||
const m = match12[2] ? parseInt(match12[2]) : 0
|
||||
const isPm = match12[3].toLowerCase() === 'pm'
|
||||
if (h === 12) h = isPm ? 12 : 0
|
||||
else if (isPm) h += 12
|
||||
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface Category {
|
||||
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const photoCache = new Map<string, string | null>()
|
||||
const photoInFlight = new Set<string>()
|
||||
// Event-based notification instead of polling intervals
|
||||
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
|
||||
|
||||
function notifyListeners(key: string, url: string | null) {
|
||||
const listeners = photoListeners.get(key)
|
||||
if (listeners) {
|
||||
listeners.forEach(fn => fn(url))
|
||||
photoListeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Observe visibility — fetch photo only when avatar enters viewport
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setVisible(true); return }
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// Check if already cached — show immediately without waiting for intersection
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
|
||||
|
||||
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [place.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (photoCache.has(cacheKey)) {
|
||||
const cached = photoCache.get(cacheKey)
|
||||
if (cached) setPhotoSrc(cached)
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached) {
|
||||
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
|
||||
if (!cached.thumbDataUrl && cached.photoUrl) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (photoInFlight.has(cacheKey)) {
|
||||
// Subscribe to notification instead of polling
|
||||
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
||||
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
||||
photoListeners.get(cacheKey)!.add(handler)
|
||||
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
||||
if (isLoading(cacheKey)) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
|
||||
photoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
const url = data.photoUrl || null
|
||||
photoCache.set(cacheKey, url)
|
||||
if (url) setPhotoSrc(url)
|
||||
notifyListeners(cacheKey, url)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => {
|
||||
photoCache.set(cacheKey, null)
|
||||
notifyListeners(cacheKey, null)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
|
||||
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||
)
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
@@ -81,11 +76,11 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
|
||||
if (photoSrc) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
@@ -94,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
|
||||
|
||||
export function useInAppNotificationListener(): void {
|
||||
const handleNew = useInAppNotificationStore(s => s.handleNewNotification)
|
||||
const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Record<string, unknown>) => {
|
||||
if (event.type === 'notification:new') {
|
||||
handleNew(event.notification as any)
|
||||
} else if (event.type === 'notification:updated') {
|
||||
handleUpdated(event.notification as any)
|
||||
}
|
||||
}
|
||||
addListener(listener)
|
||||
return () => removeListener(listener)
|
||||
}, [handleNew, handleUpdated])
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef, useReducer } from 'react'
|
||||
|
||||
export interface UndoEntry {
|
||||
label: string
|
||||
undo: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function usePlannerHistory(maxEntries = 30) {
|
||||
const historyRef = useRef<UndoEntry[]>([])
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
|
||||
|
||||
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
|
||||
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries)
|
||||
forceUpdate()
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
if (historyRef.current.length === 0) return
|
||||
const [first, ...rest] = historyRef.current
|
||||
historyRef.current = rest
|
||||
forceUpdate()
|
||||
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
|
||||
}
|
||||
|
||||
const canUndo = historyRef.current.length > 0
|
||||
const lastActionLabel = historyRef.current[0]?.label ?? null
|
||||
|
||||
return { pushUndo, undo, canUndo, lastActionLabel }
|
||||
}
|
||||
@@ -15,11 +15,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
@@ -33,12 +37,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [tripStore, routeCalcEnabled])
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, tripStore.assignments])
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
@@ -24,14 +25,15 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs, pl }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
@@ -40,7 +42,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
|
||||
+1549
-1346
File diff suppressed because it is too large
Load Diff
+1544
-1341
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Upravit',
|
||||
'common.add': 'Přidat',
|
||||
'common.loading': 'Načítání...',
|
||||
'common.import': 'Importovat',
|
||||
'common.error': 'Chyba',
|
||||
'common.back': 'Zpět',
|
||||
'common.all': 'Vše',
|
||||
@@ -25,6 +26,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Heslo',
|
||||
'common.saving': 'Ukládání...',
|
||||
'common.saved': 'Uloženo',
|
||||
'trips.reminder': 'Připomínka',
|
||||
'trips.reminderNone': 'Žádná',
|
||||
'trips.reminderDay': 'den',
|
||||
'trips.reminderDays': 'dní',
|
||||
'trips.reminderCustom': 'Vlastní',
|
||||
'trips.reminderDaysBefore': 'dní před odjezdem',
|
||||
'trips.reminderDisabledHint': 'Připomínky výletů jsou zakázány. Povolte je v Správa > Nastavení > Oznámení.',
|
||||
'common.update': 'Aktualizovat',
|
||||
'common.change': 'Změnit',
|
||||
'common.uploading': 'Nahrávání…',
|
||||
@@ -72,7 +81,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Sdílí {name}',
|
||||
'dashboard.days': 'Dní',
|
||||
'dashboard.places': 'Míst',
|
||||
'dashboard.members': 'Cestovní parťáci',
|
||||
'dashboard.archive': 'Archivovat',
|
||||
'dashboard.copyTrip': 'Kopírovat',
|
||||
'dashboard.copySuffix': 'kopie',
|
||||
'dashboard.restore': 'Obnovit',
|
||||
'dashboard.archived': 'Archivováno',
|
||||
'dashboard.status.ongoing': 'Probíhající',
|
||||
@@ -91,7 +103,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu',
|
||||
'dashboard.toast.restored': 'Cesta byla obnovena',
|
||||
'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu',
|
||||
'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.',
|
||||
'dashboard.toast.copied': 'Cesta byla zkopírována!',
|
||||
'dashboard.toast.copyError': 'Nepodařilo se zkopírovat cestu',
|
||||
'dashboard.confirm.delete': 'Smazat cestu „{title}”? Všechna místa a plány budou trvale smazány.',
|
||||
'dashboard.editTrip': 'Upravit cestu',
|
||||
'dashboard.createTrip': 'Vytvořit novou cestu',
|
||||
'dashboard.tripTitle': 'Název',
|
||||
@@ -150,9 +164,38 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
|
||||
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
|
||||
'settings.notifyWebhook': 'Webhook oznámení',
|
||||
'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.',
|
||||
'settings.notificationsActive': 'Aktivní kanál',
|
||||
'settings.notificationsManagedByAdmin': 'Události oznámení jsou konfigurovány administrátorem.',
|
||||
'settings.on': 'Zapnuto',
|
||||
'settings.off': 'Vypnuto',
|
||||
'settings.mcp.title': 'Konfigurace MCP',
|
||||
'settings.mcp.endpoint': 'MCP endpoint',
|
||||
'settings.mcp.clientConfig': 'Konfigurace klienta',
|
||||
'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
|
||||
'settings.mcp.copy': 'Kopírovat',
|
||||
'settings.mcp.copied': 'Zkopírováno!',
|
||||
'settings.mcp.apiTokens': 'API tokeny',
|
||||
'settings.mcp.createToken': 'Vytvořit nový token',
|
||||
'settings.mcp.noTokens': 'Zatím žádné tokeny. Vytvořte jeden pro připojení MCP klientů.',
|
||||
'settings.mcp.tokenCreatedAt': 'Vytvořen',
|
||||
'settings.mcp.tokenUsedAt': 'Použit',
|
||||
'settings.mcp.deleteTokenTitle': 'Smazat token',
|
||||
'settings.mcp.deleteTokenMessage': 'Tento token přestane okamžitě fungovat. Všichni MCP klienti, kteří ho používají, ztratí přístup.',
|
||||
'settings.mcp.modal.createTitle': 'Vytvořit API token',
|
||||
'settings.mcp.modal.tokenName': 'Název tokenu',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'např. Claude Desktop, Pracovní notebook',
|
||||
'settings.mcp.modal.creating': 'Vytváření…',
|
||||
'settings.mcp.modal.create': 'Vytvořit token',
|
||||
'settings.mcp.modal.createdTitle': 'Token vytvořen',
|
||||
'settings.mcp.modal.createdWarning': 'Tento token bude zobrazen pouze jednou. Zkopírujte a uložte ho nyní — nelze ho obnovit.',
|
||||
'settings.mcp.modal.done': 'Hotovo',
|
||||
'settings.mcp.toast.created': 'Token vytvořen',
|
||||
'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
|
||||
'settings.mcp.toast.deleted': 'Token smazán',
|
||||
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
|
||||
'settings.account': 'Účet',
|
||||
'settings.about': 'O aplikaci',
|
||||
'settings.username': 'Uživatelské jméno',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Role',
|
||||
@@ -167,7 +210,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
|
||||
'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
|
||||
'settings.passwordMismatch': 'Hesla se neshodují',
|
||||
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici',
|
||||
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak',
|
||||
'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
|
||||
'settings.deleteAccount': 'Smazat účet',
|
||||
'settings.deleteAccountTitle': 'Smazat váš účet?',
|
||||
@@ -188,6 +231,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Nahrávání se nezdařilo',
|
||||
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
|
||||
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
|
||||
'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.',
|
||||
'settings.mfa.backupTitle': 'Záložní kódy',
|
||||
'settings.mfa.backupDescription': 'Použijte tyto jednorázové kódy, pokud ztratíte přístup k autentizační aplikaci.',
|
||||
'settings.mfa.backupWarning': 'Uložte si je hned. Každý kód lze použít pouze jednou.',
|
||||
'settings.mfa.backupCopy': 'Kopírovat kódy',
|
||||
'settings.mfa.backupDownload': 'Stáhnout TXT',
|
||||
'settings.mfa.backupPrint': 'Tisk / PDF',
|
||||
'settings.mfa.backupCopied': 'Záložní kódy zkopírovány',
|
||||
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
|
||||
'settings.mfa.disabled': '2FA není aktivní.',
|
||||
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
|
||||
@@ -202,9 +253,23 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
|
||||
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
|
||||
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
|
||||
'admin.notifications.title': 'Oznámení',
|
||||
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
|
||||
'admin.notifications.none': 'Vypnuto',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Události oznámení',
|
||||
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
|
||||
'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
|
||||
'admin.notifications.save': 'Uložit nastavení oznámení',
|
||||
'admin.notifications.saved': 'Nastavení oznámení uloženo',
|
||||
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Testovací webhook úspěšně odeslán',
|
||||
'admin.notifications.testWebhookFailed': 'Odeslání testovacího webhooku se nezdařilo',
|
||||
'admin.smtp.title': 'E-mail a oznámení',
|
||||
'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.',
|
||||
'admin.smtp.hint': 'Konfigurace SMTP pro odesílání e-mailových oznámení.',
|
||||
'admin.smtp.testButton': 'Odeslat testovací e-mail',
|
||||
'admin.webhook.hint': 'Odesílat oznámení na externí webhook (Discord, Slack atd.).',
|
||||
'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
|
||||
'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
|
||||
'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
|
||||
@@ -264,6 +329,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Přihlásit se',
|
||||
'login.createAdmin': 'Vytvořit účet administrátora',
|
||||
'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
|
||||
'login.setNewPassword': 'Nastavit nové heslo',
|
||||
'login.setNewPasswordHint': 'Před pokračováním musíte změnit heslo.',
|
||||
'login.createAccount': 'Vytvořit účet',
|
||||
'login.createAccountHint': 'Zaregistrujte si nový účet.',
|
||||
'login.creating': 'Vytváření…',
|
||||
@@ -290,7 +357,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Registrace (Register)
|
||||
'register.passwordMismatch': 'Hesla se neshodují',
|
||||
'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků',
|
||||
'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
|
||||
'register.failed': 'Registrace se nezdařila',
|
||||
'register.getStarted': 'Začínáme',
|
||||
'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',
|
||||
@@ -365,6 +432,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Nastavení',
|
||||
'admin.allowRegistration': 'Povolit registraci',
|
||||
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
||||
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
|
||||
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
|
||||
'admin.apiKeys': 'API klíče',
|
||||
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
|
||||
'admin.mapsKey': 'Google Maps API klíč',
|
||||
@@ -419,8 +488,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Doplňky',
|
||||
'admin.addons.title': 'Doplňky',
|
||||
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
|
||||
'admin.addons.catalog.memories.name': 'Vzpomínky',
|
||||
'admin.addons.catalog.memories.description': 'Sdílená fotoalba pro každou cestu',
|
||||
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
|
||||
'admin.addons.catalog.packing.name': 'Balení',
|
||||
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
|
||||
'admin.addons.catalog.budget.name': 'Rozpočet',
|
||||
@@ -437,13 +506,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.disabled': 'Zakázáno',
|
||||
'admin.addons.type.trip': 'Cesta',
|
||||
'admin.addons.type.global': 'Globální',
|
||||
'admin.addons.type.integration': 'Integrace',
|
||||
'admin.addons.tripHint': 'Dostupné jako karta v rámci každé cesty',
|
||||
'admin.addons.globalHint': 'Dostupné jako samostatná sekce v hlavní navigaci',
|
||||
'admin.addons.integrationHint': 'Backendové služby a API integrace bez vlastní stránky',
|
||||
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
|
||||
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
|
||||
'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
|
||||
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů',
|
||||
'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
|
||||
@@ -461,6 +532,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Detaily',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP tokeny',
|
||||
'admin.mcpTokens.title': 'MCP tokeny',
|
||||
'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů',
|
||||
'admin.mcpTokens.owner': 'Vlastník',
|
||||
'admin.mcpTokens.tokenName': 'Název tokenu',
|
||||
'admin.mcpTokens.created': 'Vytvořen',
|
||||
'admin.mcpTokens.lastUsed': 'Naposledy použit',
|
||||
'admin.mcpTokens.never': 'Nikdy',
|
||||
'admin.mcpTokens.empty': 'Zatím nebyly vytvořeny žádné MCP tokeny',
|
||||
'admin.mcpTokens.deleteTitle': 'Smazat token',
|
||||
'admin.mcpTokens.deleteMessage': 'Tento token bude okamžitě zneplatněn. Uživatel ztratí MCP přístup přes tento token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token smazán',
|
||||
'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
|
||||
'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Historie verzí',
|
||||
@@ -510,7 +597,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Plánování a správa dovolené',
|
||||
'vacay.settings': 'Nastavení',
|
||||
'vacay.year': 'Rok',
|
||||
'vacay.addYear': 'Přidat rok',
|
||||
'vacay.addYear': 'Přidat následující rok',
|
||||
'vacay.addPrevYear': 'Přidat předchozí rok',
|
||||
'vacay.removeYear': 'Odebrat rok',
|
||||
'vacay.removeYearConfirm': 'Odebrat rok {year}?',
|
||||
'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',
|
||||
@@ -612,7 +700,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Statistiky',
|
||||
'atlas.bucketTab': 'Bucket List',
|
||||
'atlas.addBucket': 'Přidat na Bucket List',
|
||||
'atlas.bucketNamePlaceholder': 'Místo nebo destinace...',
|
||||
'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)',
|
||||
'atlas.bucketEmpty': 'Váš seznam přání je prázdný',
|
||||
'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit',
|
||||
@@ -655,6 +742,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.budget': 'Rozpočet',
|
||||
'trip.tabs.files': 'Soubory',
|
||||
'trip.loading': 'Načítání cesty...',
|
||||
'trip.loadingPhotos': 'Načítání fotek míst...',
|
||||
'trip.mobilePlan': 'Plán',
|
||||
'trip.mobilePlaces': 'Místa',
|
||||
'trip.toast.placeUpdated': 'Místo bylo aktualizováno',
|
||||
@@ -701,10 +789,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Boční panel míst (Places Sidebar)
|
||||
'places.addPlace': 'Přidat místo/aktivitu',
|
||||
'places.importGpx': 'Importovat GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} míst importováno z GPX',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.gpxError': 'Import GPX se nezdařil',
|
||||
'places.importGoogleList': 'Google Seznam',
|
||||
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
|
||||
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
|
||||
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
|
||||
'places.viewDetails': 'Zobrazit detaily',
|
||||
'places.assignToDay': 'Přidat do kterého dne?',
|
||||
'places.all': 'Vše',
|
||||
'places.unplanned': 'Nezařazené',
|
||||
@@ -761,6 +854,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Rezervace',
|
||||
'inspector.editRes': 'Upravit rezervaci',
|
||||
'inspector.participants': 'Účastníci',
|
||||
'inspector.trackStats': 'Data trasy',
|
||||
|
||||
// Rezervace (Reservations)
|
||||
'reservations.title': 'Rezervace',
|
||||
@@ -843,6 +937,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Rozpočet (Budget)
|
||||
'budget.title': 'Rozpočet',
|
||||
'budget.exportCsv': 'Exportovat CSV',
|
||||
'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet',
|
||||
'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu',
|
||||
'budget.emptyPlaceholder': 'Zadejte název kategorie...',
|
||||
@@ -857,6 +952,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Za den',
|
||||
'budget.table.perPersonDay': 'Os. / den',
|
||||
'budget.table.note': 'Poznámka',
|
||||
'budget.table.date': 'Datum',
|
||||
'budget.newEntry': 'Nová položka',
|
||||
'budget.defaultEntry': 'Nová položka',
|
||||
'budget.defaultCategory': 'Nová kategorie',
|
||||
@@ -1250,12 +1346,19 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'URL serveru Immich',
|
||||
'memories.immichApiKey': 'API klíč',
|
||||
'memories.testConnection': 'Otestovat připojení',
|
||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||
'memories.connected': 'Připojeno',
|
||||
'memories.disconnected': 'Nepřipojeno',
|
||||
'memories.connectionSuccess': 'Připojeno k Immich',
|
||||
'memories.connectionError': 'Nepodařilo se připojit k Immich',
|
||||
'memories.saved': 'Nastavení Immich uloženo',
|
||||
'memories.addPhotos': 'Přidat fotky',
|
||||
'memories.linkAlbum': 'Propojit album',
|
||||
'memories.selectAlbum': 'Vybrat album z Immich',
|
||||
'memories.noAlbums': 'Žádná alba nenalezena',
|
||||
'memories.syncAlbum': 'Synchronizovat album',
|
||||
'memories.unlinkAlbum': 'Odpojit',
|
||||
'memories.photos': 'fotek',
|
||||
'memories.selectPhotos': 'Vybrat fotky z Immich',
|
||||
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
|
||||
'memories.selected': 'vybráno',
|
||||
@@ -1290,6 +1393,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Dnes',
|
||||
'collab.chat.yesterday': 'Včera',
|
||||
'collab.chat.deletedMessage': 'smazal zprávu',
|
||||
'collab.chat.reply': 'Odpovědět',
|
||||
'collab.chat.loadMore': 'Načíst starší zprávy',
|
||||
'collab.chat.justNow': 'právě teď',
|
||||
'collab.chat.minutesAgo': 'před {n} min',
|
||||
@@ -1340,6 +1444,106 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'Možnosti',
|
||||
'collab.polls.delete': 'Smazat',
|
||||
'collab.polls.closedSection': 'Uzavřené',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Oprávnění',
|
||||
'perm.title': 'Nastavení oprávnění',
|
||||
'perm.subtitle': 'Určete, kdo může provádět akce v aplikaci',
|
||||
'perm.saved': 'Nastavení oprávnění uloženo',
|
||||
'perm.resetDefaults': 'Obnovit výchozí',
|
||||
'perm.customized': 'upraveno',
|
||||
'perm.level.admin': 'Pouze administrátor',
|
||||
'perm.level.tripOwner': 'Vlastník výletu',
|
||||
'perm.level.tripMember': 'Členové výletu',
|
||||
'perm.level.everybody': 'Všichni',
|
||||
'perm.cat.trip': 'Správa výletů',
|
||||
'perm.cat.members': 'Správa členů',
|
||||
'perm.cat.files': 'Soubory',
|
||||
'perm.cat.content': 'Obsah a plán',
|
||||
'perm.cat.extras': 'Rozpočet, balení a spolupráce',
|
||||
'perm.action.trip_create': 'Vytvářet výlety',
|
||||
'perm.action.trip_edit': 'Upravit detaily výletu',
|
||||
'perm.action.trip_delete': 'Smazat výlety',
|
||||
'perm.action.trip_archive': 'Archivovat / odarchivovat výlety',
|
||||
'perm.action.trip_cover_upload': 'Nahrát titulní obrázek',
|
||||
'perm.action.member_manage': 'Přidat / odebrat členy',
|
||||
'perm.action.file_upload': 'Nahrát soubory',
|
||||
'perm.action.file_edit': 'Upravit metadata souborů',
|
||||
'perm.action.file_delete': 'Smazat soubory',
|
||||
'perm.action.place_edit': 'Přidat / upravit / smazat místa',
|
||||
'perm.action.day_edit': 'Upravit dny, poznámky a přiřazení',
|
||||
'perm.action.reservation_edit': 'Spravovat rezervace',
|
||||
'perm.action.budget_edit': 'Spravovat rozpočet',
|
||||
'perm.action.packing_edit': 'Spravovat seznamy balení',
|
||||
'perm.action.collab_edit': 'Spolupráce (poznámky, hlasování, chat)',
|
||||
'perm.action.share_manage': 'Spravovat odkazy ke sdílení',
|
||||
'perm.actionHint.trip_create': 'Kdo může vytvářet nové výlety',
|
||||
'perm.actionHint.trip_edit': 'Kdo může měnit název, data, popis a měnu výletu',
|
||||
'perm.actionHint.trip_delete': 'Kdo může trvale smazat výlet',
|
||||
'perm.actionHint.trip_archive': 'Kdo může archivovat nebo odarchivovat výlet',
|
||||
'perm.actionHint.trip_cover_upload': 'Kdo může nahrát nebo změnit titulní obrázek',
|
||||
'perm.actionHint.member_manage': 'Kdo může pozvat nebo odebrat členy výletu',
|
||||
'perm.actionHint.file_upload': 'Kdo může nahrávat soubory k výletu',
|
||||
'perm.actionHint.file_edit': 'Kdo může upravovat popisy a odkazy souborů',
|
||||
'perm.actionHint.file_delete': 'Kdo může přesunout soubory do koše nebo je trvale smazat',
|
||||
'perm.actionHint.place_edit': 'Kdo může přidávat, upravovat nebo mazat místa',
|
||||
'perm.actionHint.day_edit': 'Kdo může upravovat dny, poznámky ke dnům a přiřazení míst',
|
||||
'perm.actionHint.reservation_edit': 'Kdo může vytvářet, upravovat nebo mazat rezervace',
|
||||
'perm.actionHint.budget_edit': 'Kdo může vytvářet, upravovat nebo mazat položky rozpočtu',
|
||||
'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky',
|
||||
'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
|
||||
'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
|
||||
// Undo
|
||||
'undo.button': 'Zpět',
|
||||
'undo.tooltip': 'Zpět: {action}',
|
||||
'undo.assignPlace': 'Místo přiřazeno ke dni',
|
||||
'undo.removeAssignment': 'Místo odebráno ze dne',
|
||||
'undo.reorder': 'Místa přeseřazena',
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
'undo.importGoogleList': 'Import z Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Oznámení',
|
||||
'notifications.markAllRead': 'Označit vše jako přečtené',
|
||||
'notifications.deleteAll': 'Smazat vše',
|
||||
'notifications.showAll': 'Zobrazit všechna oznámení',
|
||||
'notifications.empty': 'Žádná oznámení',
|
||||
'notifications.emptyDescription': 'Vše máte přečteno!',
|
||||
'notifications.all': 'Vše',
|
||||
'notifications.unreadOnly': 'Nepřečtené',
|
||||
'notifications.markRead': 'Označit jako přečtené',
|
||||
'notifications.markUnread': 'Označit jako nepřečtené',
|
||||
'notifications.delete': 'Smazat',
|
||||
'notifications.system': 'Systém',
|
||||
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
|
||||
'atlas.searchCountry': 'Hledat zemi...',
|
||||
'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
|
||||
'memories.error.linkAlbum': 'Propojení alba se nezdařilo',
|
||||
'memories.error.unlinkAlbum': 'Odpojení alba se nezdařilo',
|
||||
'memories.error.syncAlbum': 'Synchronizace alba se nezdařila',
|
||||
'memories.error.loadPhotos': 'Načtení fotek se nezdařilo',
|
||||
'memories.error.addPhotos': 'Přidání fotek se nezdařilo',
|
||||
'memories.error.removePhoto': 'Odebrání fotky se nezdařilo',
|
||||
'memories.error.toggleSharing': 'Aktualizace sdílení se nezdařila',
|
||||
'undo.addPlace': 'Místo přidáno',
|
||||
'undo.done': 'Vráceno zpět: {action}',
|
||||
'notifications.test.title': 'Testovací oznámení od {actor}',
|
||||
'notifications.test.text': 'Toto je jednoduché testovací oznámení.',
|
||||
'notifications.test.booleanTitle': '{actor} žádá o vaše schválení',
|
||||
'notifications.test.booleanText': 'Testovací oznámení s volbou.',
|
||||
'notifications.test.accept': 'Schválit',
|
||||
'notifications.test.decline': 'Odmítnout',
|
||||
'notifications.test.navigateTitle': 'Podívejte se na toto',
|
||||
'notifications.test.navigateText': 'Testovací navigační oznámení.',
|
||||
'notifications.test.goThere': 'Přejít tam',
|
||||
'notifications.test.adminTitle': 'Hromadná zpráva pro správce',
|
||||
'notifications.test.adminText': '{actor} odeslal testovací oznámení všem správcům.',
|
||||
'notifications.test.tripTitle': '{actor} přispěl do vašeho výletu',
|
||||
'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".',
|
||||
}
|
||||
|
||||
export default cs
|
||||
export default cs
|
||||
+1546
-1341
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.import': 'Import',
|
||||
'common.error': 'Error',
|
||||
'common.back': 'Back',
|
||||
'common.all': 'All',
|
||||
@@ -25,6 +26,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
'common.saving': 'Saving...',
|
||||
'common.saved': 'Saved',
|
||||
'trips.reminder': 'Reminder',
|
||||
'trips.reminderNone': 'None',
|
||||
'trips.reminderDay': 'day',
|
||||
'trips.reminderDays': 'days',
|
||||
'trips.reminderCustom': 'Custom',
|
||||
'trips.reminderDaysBefore': 'days before departure',
|
||||
'trips.reminderDisabledHint': 'Trip reminders are disabled. Enable them in Admin > Settings > Notifications.',
|
||||
'common.update': 'Update',
|
||||
'common.change': 'Change',
|
||||
'common.uploading': 'Uploading…',
|
||||
@@ -71,7 +80,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Shared by {name}',
|
||||
'dashboard.days': 'Days',
|
||||
'dashboard.places': 'Places',
|
||||
'dashboard.members': 'Buddies',
|
||||
'dashboard.archive': 'Archive',
|
||||
'dashboard.copyTrip': 'Copy',
|
||||
'dashboard.copySuffix': 'copy',
|
||||
'dashboard.restore': 'Restore',
|
||||
'dashboard.archived': 'Archived',
|
||||
'dashboard.status.ongoing': 'Ongoing',
|
||||
@@ -90,6 +102,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Failed to archive trip',
|
||||
'dashboard.toast.restored': 'Trip restored',
|
||||
'dashboard.toast.restoreError': 'Failed to restore trip',
|
||||
'dashboard.toast.copied': 'Trip copied!',
|
||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||
'dashboard.editTrip': 'Edit Trip',
|
||||
'dashboard.createTrip': 'Create New Trip',
|
||||
@@ -149,11 +163,28 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||
'settings.notifyWebhook': 'Webhook notifications',
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||
'admin.notifications.none': 'Disabled',
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Notification Events',
|
||||
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||
'admin.notifications.save': 'Save notification settings',
|
||||
'admin.notifications.saved': 'Notification settings saved',
|
||||
'admin.notifications.testWebhook': 'Send test webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||
'admin.smtp.testFailed': 'Test email failed',
|
||||
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||
'settings.notificationsActive': 'Active channel',
|
||||
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||
'share.linkTitle': 'Public Link',
|
||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||
@@ -185,7 +216,33 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'On',
|
||||
'settings.off': 'Off',
|
||||
'settings.mcp.title': 'MCP Configuration',
|
||||
'settings.mcp.endpoint': 'MCP Endpoint',
|
||||
'settings.mcp.clientConfig': 'Client Configuration',
|
||||
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
|
||||
'settings.mcp.copy': 'Copy',
|
||||
'settings.mcp.copied': 'Copied!',
|
||||
'settings.mcp.apiTokens': 'API Tokens',
|
||||
'settings.mcp.createToken': 'Create New Token',
|
||||
'settings.mcp.noTokens': 'No tokens yet. Create one to connect MCP clients.',
|
||||
'settings.mcp.tokenCreatedAt': 'Created',
|
||||
'settings.mcp.tokenUsedAt': 'Used',
|
||||
'settings.mcp.deleteTokenTitle': 'Delete Token',
|
||||
'settings.mcp.deleteTokenMessage': 'This token will stop working immediately. Any MCP client using it will lose access.',
|
||||
'settings.mcp.modal.createTitle': 'Create API Token',
|
||||
'settings.mcp.modal.tokenName': 'Token Name',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'e.g. Claude Desktop, Work laptop',
|
||||
'settings.mcp.modal.creating': 'Creating…',
|
||||
'settings.mcp.modal.create': 'Create Token',
|
||||
'settings.mcp.modal.createdTitle': 'Token Created',
|
||||
'settings.mcp.modal.createdWarning': 'This token will only be shown once. Copy and store it now — it cannot be recovered.',
|
||||
'settings.mcp.modal.done': 'Done',
|
||||
'settings.mcp.toast.created': 'Token created',
|
||||
'settings.mcp.toast.createError': 'Failed to create token',
|
||||
'settings.mcp.toast.deleted': 'Token deleted',
|
||||
'settings.mcp.toast.deleteError': 'Failed to delete token',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'About',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Role',
|
||||
@@ -200,8 +257,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Please enter current and new password',
|
||||
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||
'settings.passwordMismatch': 'Passwords do not match',
|
||||
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
|
||||
'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character',
|
||||
'settings.passwordChanged': 'Password changed successfully',
|
||||
'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
|
||||
'settings.deleteAccount': 'Delete account',
|
||||
'settings.deleteAccountTitle': 'Delete your account?',
|
||||
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
||||
@@ -221,6 +279,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Upload failed',
|
||||
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.',
|
||||
'settings.mfa.backupTitle': 'Backup codes',
|
||||
'settings.mfa.backupDescription': 'Use these one-time backup codes if you lose access to your authenticator app.',
|
||||
'settings.mfa.backupWarning': 'Save these codes now. Each code can only be used once.',
|
||||
'settings.mfa.backupCopy': 'Copy codes',
|
||||
'settings.mfa.backupDownload': 'Download TXT',
|
||||
'settings.mfa.backupPrint': 'Print / PDF',
|
||||
'settings.mfa.backupCopied': 'Backup codes copied',
|
||||
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||
'settings.mfa.disabled': '2FA is not enabled.',
|
||||
'settings.mfa.setup': 'Set up authenticator',
|
||||
@@ -263,6 +329,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Sign In',
|
||||
'login.createAdmin': 'Create Admin Account',
|
||||
'login.createAdminHint': 'Set up the first admin account for TREK.',
|
||||
'login.setNewPassword': 'Set New Password',
|
||||
'login.setNewPasswordHint': 'You must change your password before continuing.',
|
||||
'login.createAccount': 'Create Account',
|
||||
'login.createAccountHint': 'Register a new account.',
|
||||
'login.creating': 'Creating…',
|
||||
@@ -289,7 +357,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwords do not match',
|
||||
'register.passwordTooShort': 'Password must be at least 6 characters',
|
||||
'register.passwordTooShort': 'Password must be at least 8 characters',
|
||||
'register.failed': 'Registration failed',
|
||||
'register.getStarted': 'Get Started',
|
||||
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||
@@ -365,6 +433,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Settings',
|
||||
'admin.allowRegistration': 'Allow Registration',
|
||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||
'admin.requireMfa': 'Require two-factor authentication (2FA)',
|
||||
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
|
||||
'admin.apiKeys': 'API Keys',
|
||||
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
@@ -433,14 +503,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol for AI assistant integration',
|
||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||
'admin.addons.subtitleAfter': ' experience.',
|
||||
'admin.addons.enabled': 'Enabled',
|
||||
'admin.addons.disabled': 'Disabled',
|
||||
'admin.addons.type.trip': 'Trip',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integration',
|
||||
'admin.addons.tripHint': 'Available as a tab within each trip',
|
||||
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
||||
'admin.addons.integrationHint': 'Backend services and API integrations with no dedicated page',
|
||||
'admin.addons.toast.updated': 'Addon updated',
|
||||
'admin.addons.toast.error': 'Failed to update addon',
|
||||
'admin.addons.noAddons': 'No addons available',
|
||||
@@ -457,6 +531,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.mcpTokens': 'MCP Tokens',
|
||||
'admin.mcpTokens.title': 'MCP Tokens',
|
||||
'admin.mcpTokens.subtitle': 'Manage API tokens across all users',
|
||||
'admin.mcpTokens.owner': 'Owner',
|
||||
'admin.mcpTokens.tokenName': 'Token Name',
|
||||
'admin.mcpTokens.created': 'Created',
|
||||
'admin.mcpTokens.lastUsed': 'Last Used',
|
||||
'admin.mcpTokens.never': 'Never',
|
||||
'admin.mcpTokens.empty': 'No MCP tokens have been created yet',
|
||||
'admin.mcpTokens.deleteTitle': 'Delete Token',
|
||||
'admin.mcpTokens.deleteMessage': 'This will revoke the token immediately. The user will lose MCP access through this token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token deleted',
|
||||
'admin.mcpTokens.deleteError': 'Failed to delete token',
|
||||
'admin.mcpTokens.loadError': 'Failed to load tokens',
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||
@@ -504,7 +592,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Plan and manage vacation days',
|
||||
'vacay.settings': 'Settings',
|
||||
'vacay.year': 'Year',
|
||||
'vacay.addYear': 'Add year',
|
||||
'vacay.addYear': 'Add next year',
|
||||
'vacay.addPrevYear': 'Add previous year',
|
||||
'vacay.removeYear': 'Remove year',
|
||||
'vacay.removeYearConfirm': 'Remove {year}?',
|
||||
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
||||
@@ -600,6 +689,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.searchCountry': 'Search a country...',
|
||||
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||
'atlas.month': 'Month',
|
||||
'atlas.year': 'Year',
|
||||
@@ -608,7 +698,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Stats',
|
||||
'atlas.bucketTab': 'Bucket List',
|
||||
'atlas.addBucket': 'Add to bucket list',
|
||||
'atlas.bucketNamePlaceholder': 'Place or destination...',
|
||||
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
||||
'atlas.bucketEmpty': 'Your bucket list is empty',
|
||||
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
||||
@@ -621,7 +710,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.nextTrip': 'Next trip',
|
||||
'atlas.daysLeft': 'days left',
|
||||
'atlas.streak': 'Streak',
|
||||
'atlas.year': 'year',
|
||||
'atlas.years': 'years',
|
||||
'atlas.yearInRow': 'year in a row',
|
||||
'atlas.yearsInRow': 'years in a row',
|
||||
@@ -651,6 +739,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Files',
|
||||
'trip.loading': 'Loading trip...',
|
||||
'trip.loadingPhotos': 'Loading place photos...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Places',
|
||||
'trip.toast.placeUpdated': 'Place updated',
|
||||
@@ -697,10 +786,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.importGpx': 'Import GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.gpxError': 'GPX import failed',
|
||||
'places.importGoogleList': 'Google List',
|
||||
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
|
||||
'places.googleListImported': '{count} places imported from "{list}"',
|
||||
'places.googleListError': 'Failed to import Google Maps list',
|
||||
'places.viewDetails': 'View Details',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
@@ -756,6 +850,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Reservation',
|
||||
'inspector.editRes': 'Edit Reservation',
|
||||
'inspector.participants': 'Participants',
|
||||
'inspector.trackStats': 'Track Stats',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Bookings',
|
||||
@@ -838,6 +933,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'Export CSV',
|
||||
'budget.emptyTitle': 'No budget created yet',
|
||||
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
||||
'budget.emptyPlaceholder': 'Enter category name...',
|
||||
@@ -852,6 +948,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Per Day',
|
||||
'budget.table.perPersonDay': 'P. p / Day',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.table.date': 'Date',
|
||||
'budget.newEntry': 'New Entry',
|
||||
'budget.defaultEntry': 'New Entry',
|
||||
'budget.defaultCategory': 'New Category',
|
||||
@@ -1245,12 +1342,19 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API Key',
|
||||
'memories.testConnection': 'Test connection',
|
||||
'memories.testFirst': 'Test connection first',
|
||||
'memories.connected': 'Connected',
|
||||
'memories.disconnected': 'Not connected',
|
||||
'memories.connectionSuccess': 'Connected to Immich',
|
||||
'memories.connectionError': 'Could not connect to Immich',
|
||||
'memories.saved': 'Immich settings saved',
|
||||
'memories.addPhotos': 'Add photos',
|
||||
'memories.linkAlbum': 'Link Album',
|
||||
'memories.selectAlbum': 'Select Immich Album',
|
||||
'memories.noAlbums': 'No albums found',
|
||||
'memories.syncAlbum': 'Sync album',
|
||||
'memories.unlinkAlbum': 'Unlink album',
|
||||
'memories.photos': 'photos',
|
||||
'memories.selectPhotos': 'Select photos from Immich',
|
||||
'memories.selectHint': 'Tap photos to select them.',
|
||||
'memories.selected': 'selected',
|
||||
@@ -1266,6 +1370,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'Share with trip members?',
|
||||
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||
'memories.confirmShareButton': 'Share photos',
|
||||
'memories.error.loadAlbums': 'Failed to load albums',
|
||||
'memories.error.linkAlbum': 'Failed to link album',
|
||||
'memories.error.unlinkAlbum': 'Failed to unlink album',
|
||||
'memories.error.syncAlbum': 'Failed to sync album',
|
||||
'memories.error.loadPhotos': 'Failed to load photos',
|
||||
'memories.error.addPhotos': 'Failed to add photos',
|
||||
'memories.error.removePhoto': 'Failed to remove photo',
|
||||
'memories.error.toggleSharing': 'Failed to update sharing',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
@@ -1285,6 +1397,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Today',
|
||||
'collab.chat.yesterday': 'Yesterday',
|
||||
'collab.chat.deletedMessage': 'deleted a message',
|
||||
'collab.chat.reply': 'Reply',
|
||||
'collab.chat.loadMore': 'Load older messages',
|
||||
'collab.chat.justNow': 'just now',
|
||||
'collab.chat.minutesAgo': '{n}m ago',
|
||||
@@ -1335,6 +1448,99 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'Options',
|
||||
'collab.polls.delete': 'Delete',
|
||||
'collab.polls.closedSection': 'Closed',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Permissions',
|
||||
'perm.title': 'Permission Settings',
|
||||
'perm.subtitle': 'Control who can perform actions across the application',
|
||||
'perm.saved': 'Permission settings saved',
|
||||
'perm.resetDefaults': 'Reset to defaults',
|
||||
'perm.customized': 'customized',
|
||||
'perm.level.admin': 'Admin only',
|
||||
'perm.level.tripOwner': 'Trip owner',
|
||||
'perm.level.tripMember': 'Trip members',
|
||||
'perm.level.everybody': 'Everyone',
|
||||
'perm.cat.trip': 'Trip Management',
|
||||
'perm.cat.members': 'Member Management',
|
||||
'perm.cat.files': 'Files',
|
||||
'perm.cat.content': 'Content & Schedule',
|
||||
'perm.cat.extras': 'Budget, Packing & Collaboration',
|
||||
'perm.action.trip_create': 'Create trips',
|
||||
'perm.action.trip_edit': 'Edit trip details',
|
||||
'perm.action.trip_delete': 'Delete trips',
|
||||
'perm.action.trip_archive': 'Archive / unarchive trips',
|
||||
'perm.action.trip_cover_upload': 'Upload cover image',
|
||||
'perm.action.member_manage': 'Add / remove members',
|
||||
'perm.action.file_upload': 'Upload files',
|
||||
'perm.action.file_edit': 'Edit file metadata',
|
||||
'perm.action.file_delete': 'Delete files',
|
||||
'perm.action.place_edit': 'Add / edit / delete places',
|
||||
'perm.action.day_edit': 'Edit days, notes & assignments',
|
||||
'perm.action.reservation_edit': 'Manage reservations',
|
||||
'perm.action.budget_edit': 'Manage budget',
|
||||
'perm.action.packing_edit': 'Manage packing lists',
|
||||
'perm.action.collab_edit': 'Collaboration (notes, polls, chat)',
|
||||
'perm.action.share_manage': 'Manage share links',
|
||||
'perm.actionHint.trip_create': 'Who can create new trips',
|
||||
'perm.actionHint.trip_edit': 'Who can change trip name, dates, description and currency',
|
||||
'perm.actionHint.trip_delete': 'Who can permanently delete a trip',
|
||||
'perm.actionHint.trip_archive': 'Who can archive or unarchive a trip',
|
||||
'perm.actionHint.trip_cover_upload': 'Who can upload or change the cover image',
|
||||
'perm.actionHint.member_manage': 'Who can invite or remove trip members',
|
||||
'perm.actionHint.file_upload': 'Who can upload files to a trip',
|
||||
'perm.actionHint.file_edit': 'Who can edit file descriptions and links',
|
||||
'perm.actionHint.file_delete': 'Who can move files to trash or permanently delete them',
|
||||
'perm.actionHint.place_edit': 'Who can add, edit or delete places',
|
||||
'perm.actionHint.day_edit': 'Who can edit days, day notes and place assignments',
|
||||
'perm.actionHint.reservation_edit': 'Who can create, edit or delete reservations',
|
||||
'perm.actionHint.budget_edit': 'Who can create, edit or delete budget items',
|
||||
'perm.actionHint.packing_edit': 'Who can manage packing items and bags',
|
||||
'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages',
|
||||
'perm.actionHint.share_manage': 'Who can create or delete public share links',
|
||||
|
||||
// Undo
|
||||
'undo.button': 'Undo',
|
||||
'undo.tooltip': 'Undo: {action}',
|
||||
'undo.assignPlace': 'Place assigned to day',
|
||||
'undo.removeAssignment': 'Place removed from day',
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
'undo.importGoogleList': 'Google Maps import',
|
||||
'undo.addPlace': 'Place added',
|
||||
'undo.done': 'Undone: {action}',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.markAllRead': 'Mark all read',
|
||||
'notifications.deleteAll': 'Delete all',
|
||||
'notifications.showAll': 'Show all notifications',
|
||||
'notifications.empty': 'No notifications',
|
||||
'notifications.emptyDescription': "You're all caught up!",
|
||||
'notifications.all': 'All',
|
||||
'notifications.unreadOnly': 'Unread',
|
||||
'notifications.markRead': 'Mark as read',
|
||||
'notifications.markUnread': 'Mark as unread',
|
||||
'notifications.delete': 'Delete',
|
||||
'notifications.system': 'System',
|
||||
|
||||
// Notification test keys (dev only)
|
||||
'notifications.test.title': 'Test notification from {actor}',
|
||||
'notifications.test.text': 'This is a simple test notification.',
|
||||
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
||||
'notifications.test.booleanText': 'This is a test boolean notification. Choose an action below.',
|
||||
'notifications.test.accept': 'Approve',
|
||||
'notifications.test.decline': 'Decline',
|
||||
'notifications.test.navigateTitle': 'Check something out',
|
||||
'notifications.test.navigateText': 'This is a test navigate notification.',
|
||||
'notifications.test.goThere': 'Go there',
|
||||
'notifications.test.adminTitle': 'Admin broadcast',
|
||||
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
|
||||
'notifications.test.tripTitle': '{actor} posted in your trip',
|
||||
'notifications.test.tripText': 'Test notification for trip "{trip}".',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
+1551
-1346
File diff suppressed because it is too large
Load Diff
+1545
-1340
File diff suppressed because it is too large
Load Diff
+1546
-1340
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Modifica',
|
||||
'common.add': 'Aggiungi',
|
||||
'common.loading': 'Caricamento...',
|
||||
'common.import': 'Importa',
|
||||
'common.error': 'Errore',
|
||||
'common.back': 'Indietro',
|
||||
'common.all': 'Tutti',
|
||||
@@ -25,6 +26,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
'common.saving': 'Salvataggio...',
|
||||
'common.saved': 'Salvato',
|
||||
'trips.reminder': 'Promemoria',
|
||||
'trips.reminderNone': 'Nessuno',
|
||||
'trips.reminderDay': 'giorno',
|
||||
'trips.reminderDays': 'giorni',
|
||||
'trips.reminderCustom': 'Personalizzato',
|
||||
'trips.reminderDaysBefore': 'giorni prima della partenza',
|
||||
'trips.reminderDisabledHint': 'I promemoria dei viaggi sono disabilitati. Abilitali in Admin > Impostazioni > Notifiche.',
|
||||
'common.update': 'Aggiorna',
|
||||
'common.change': 'Cambia',
|
||||
'common.uploading': 'Caricamento…',
|
||||
@@ -71,7 +80,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Condiviso da {name}',
|
||||
'dashboard.days': 'Giorni',
|
||||
'dashboard.places': 'Luoghi',
|
||||
'dashboard.members': 'Compagni di viaggio',
|
||||
'dashboard.archive': 'Archivia',
|
||||
'dashboard.copyTrip': 'Copia',
|
||||
'dashboard.copySuffix': 'copia',
|
||||
'dashboard.restore': 'Ripristina',
|
||||
'dashboard.archived': 'Archiviati',
|
||||
'dashboard.status.ongoing': 'In corso',
|
||||
@@ -90,6 +102,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio',
|
||||
'dashboard.toast.restored': 'Viaggio ripristinato',
|
||||
'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio',
|
||||
'dashboard.toast.copied': 'Viaggio copiato!',
|
||||
'dashboard.toast.copyError': 'Impossibile copiare il viaggio',
|
||||
'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.',
|
||||
'dashboard.editTrip': 'Modifica Viaggio',
|
||||
'dashboard.createTrip': 'Crea Nuovo Viaggio',
|
||||
@@ -149,9 +163,38 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
|
||||
'settings.notifyWebhook': 'Notifiche webhook',
|
||||
'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.',
|
||||
'settings.notificationsActive': 'Canale attivo',
|
||||
'settings.notificationsManagedByAdmin': 'Gli eventi di notifica sono configurati dall\'amministratore.',
|
||||
'settings.on': 'On',
|
||||
'settings.off': 'Off',
|
||||
'settings.mcp.title': 'Configurazione MCP',
|
||||
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||
'settings.mcp.clientConfig': 'Configurazione client',
|
||||
'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
|
||||
'settings.mcp.copy': 'Copia',
|
||||
'settings.mcp.copied': 'Copiato!',
|
||||
'settings.mcp.apiTokens': 'Token API',
|
||||
'settings.mcp.createToken': 'Crea nuovo token',
|
||||
'settings.mcp.noTokens': 'Nessun token ancora. Creane uno per connettere i client MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'Creato',
|
||||
'settings.mcp.tokenUsedAt': 'Utilizzato',
|
||||
'settings.mcp.deleteTokenTitle': 'Elimina token',
|
||||
'settings.mcp.deleteTokenMessage': 'Questo token smetterà di funzionare immediatamente. Qualsiasi client MCP che lo utilizza perderà l\'accesso.',
|
||||
'settings.mcp.modal.createTitle': 'Crea token API',
|
||||
'settings.mcp.modal.tokenName': 'Nome del token',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'es. Claude Desktop, Laptop di lavoro',
|
||||
'settings.mcp.modal.creating': 'Creazione…',
|
||||
'settings.mcp.modal.create': 'Crea token',
|
||||
'settings.mcp.modal.createdTitle': 'Token creato',
|
||||
'settings.mcp.modal.createdWarning': 'Questo token verrà mostrato solo una volta. Copialo e salvalo ora — non può essere recuperato.',
|
||||
'settings.mcp.modal.done': 'Fatto',
|
||||
'settings.mcp.toast.created': 'Token creato',
|
||||
'settings.mcp.toast.createError': 'Impossibile creare il token',
|
||||
'settings.mcp.toast.deleted': 'Token eliminato',
|
||||
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Informazioni',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Ruolo',
|
||||
@@ -166,7 +209,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
|
||||
'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
|
||||
'settings.passwordMismatch': 'Le password non corrispondono',
|
||||
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero',
|
||||
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale',
|
||||
'settings.passwordChanged': 'Password cambiata con successo',
|
||||
'settings.deleteAccount': 'Elimina account',
|
||||
'settings.deleteAccountTitle': 'Eliminare il tuo account?',
|
||||
@@ -187,6 +230,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Impossibile caricare',
|
||||
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
|
||||
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
|
||||
'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.',
|
||||
'settings.mfa.backupTitle': 'Codici di backup',
|
||||
'settings.mfa.backupDescription': 'Usa questi codici monouso se perdi l\'accesso alla tua app authenticator.',
|
||||
'settings.mfa.backupWarning': 'Salvali adesso. Ogni codice può essere usato una sola volta.',
|
||||
'settings.mfa.backupCopy': 'Copia codici',
|
||||
'settings.mfa.backupDownload': 'Scarica TXT',
|
||||
'settings.mfa.backupPrint': 'Stampa / PDF',
|
||||
'settings.mfa.backupCopied': 'Codici di backup copiati',
|
||||
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
|
||||
'settings.mfa.disabled': 'La 2FA non è abilitata.',
|
||||
'settings.mfa.setup': 'Configura authenticator',
|
||||
@@ -201,9 +252,24 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
|
||||
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
|
||||
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
|
||||
'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.',
|
||||
'admin.notifications.title': 'Notifiche',
|
||||
'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.',
|
||||
'admin.notifications.none': 'Disattivato',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Eventi di notifica',
|
||||
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
|
||||
'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
|
||||
'admin.notifications.save': 'Salva impostazioni notifiche',
|
||||
'admin.notifications.saved': 'Impostazioni notifiche salvate',
|
||||
'admin.notifications.testWebhook': 'Invia webhook di test',
|
||||
'admin.notifications.testWebhookSuccess': 'Webhook di test inviato con successo',
|
||||
'admin.notifications.testWebhookFailed': 'Invio webhook di test fallito',
|
||||
'admin.smtp.title': 'Email e notifiche',
|
||||
'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.',
|
||||
'admin.smtp.hint': 'Configurazione SMTP per l\'invio delle notifiche via e-mail.',
|
||||
'admin.smtp.testButton': 'Invia email di prova',
|
||||
'admin.webhook.hint': 'Invia notifiche a un webhook esterno (Discord, Slack, ecc.).',
|
||||
'admin.smtp.testSuccess': 'Email di prova inviata con successo',
|
||||
'admin.smtp.testFailed': 'Invio email di prova fallito',
|
||||
'dayplan.icsTooltip': 'Esporta calendario (ICS)',
|
||||
@@ -263,6 +329,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Accedi',
|
||||
'login.createAdmin': 'Crea Account Amministratore',
|
||||
'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
|
||||
'login.setNewPassword': 'Imposta nuova password',
|
||||
'login.setNewPasswordHint': 'Devi cambiare la password prima di continuare.',
|
||||
'login.createAccount': 'Crea Account',
|
||||
'login.createAccountHint': 'Registra un nuovo account.',
|
||||
'login.creating': 'Creazione in corso…',
|
||||
@@ -289,7 +357,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Le password non corrispondono',
|
||||
'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri',
|
||||
'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
|
||||
'register.failed': 'Registrazione fallita',
|
||||
'register.getStarted': 'Inizia',
|
||||
'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',
|
||||
@@ -364,6 +432,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Impostazioni',
|
||||
'admin.allowRegistration': 'Consenti Registrazione',
|
||||
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
|
||||
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
|
||||
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
|
||||
'admin.apiKeys': 'Chiavi API',
|
||||
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
|
||||
'admin.mapsKey': 'Chiave API Google Maps',
|
||||
@@ -431,14 +501,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.collab.description': 'Note, sondaggi e chat in tempo reale per la pianificazione del viaggio',
|
||||
'admin.addons.catalog.memories.name': 'Foto (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol per l\'integrazione di assistenti AI',
|
||||
'admin.addons.subtitleBefore': 'Abilita o disabilita le funzionalità per personalizzare la tua ',
|
||||
'admin.addons.subtitleAfter': ' esperienza.',
|
||||
'admin.addons.enabled': 'Abilitato',
|
||||
'admin.addons.disabled': 'Disabilitato',
|
||||
'admin.addons.type.trip': 'Viaggio',
|
||||
'admin.addons.type.global': 'Globale',
|
||||
'admin.addons.type.integration': 'Integrazione',
|
||||
'admin.addons.tripHint': 'Disponibile come scheda all\'interno di ciascun viaggio',
|
||||
'admin.addons.globalHint': 'Disponibile come sezione autonoma nella navigazione principale',
|
||||
'admin.addons.integrationHint': 'Servizi backend e integrazioni API senza pagina dedicata',
|
||||
'admin.addons.toast.updated': 'Modulo aggiornato',
|
||||
'admin.addons.toast.error': 'Impossibile aggiornare il modulo',
|
||||
'admin.addons.noAddons': 'Nessun modulo disponibile',
|
||||
@@ -469,6 +543,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Dettagli',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'Token MCP',
|
||||
'admin.mcpTokens.title': 'Token MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti',
|
||||
'admin.mcpTokens.owner': 'Proprietario',
|
||||
'admin.mcpTokens.tokenName': 'Nome token',
|
||||
'admin.mcpTokens.created': 'Creato',
|
||||
'admin.mcpTokens.lastUsed': 'Ultimo utilizzo',
|
||||
'admin.mcpTokens.never': 'Mai',
|
||||
'admin.mcpTokens.empty': 'Non sono ancora stati creati token MCP',
|
||||
'admin.mcpTokens.deleteTitle': 'Elimina token',
|
||||
'admin.mcpTokens.deleteMessage': 'Questo token verrà revocato immediatamente. L\'utente perderà l\'accesso MCP tramite questo token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token eliminato',
|
||||
'admin.mcpTokens.deleteError': 'Impossibile eliminare il token',
|
||||
'admin.mcpTokens.loadError': 'Impossibile caricare i token',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Cronologia rilasci',
|
||||
@@ -505,7 +595,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
|
||||
'vacay.settings': 'Impostazioni',
|
||||
'vacay.year': 'Anno',
|
||||
'vacay.addYear': 'Aggiungi anno',
|
||||
'vacay.addYear': 'Aggiungi anno successivo',
|
||||
'vacay.addPrevYear': 'Aggiungi anno precedente',
|
||||
'vacay.removeYear': 'Rimuovi anno',
|
||||
'vacay.removeYearConfirm': 'Rimuovere {year}?',
|
||||
'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',
|
||||
@@ -608,7 +699,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Statistiche',
|
||||
'atlas.bucketTab': 'Lista desideri',
|
||||
'atlas.addBucket': 'Aggiungi alla lista desideri',
|
||||
'atlas.bucketNamePlaceholder': 'Luogo o destinazione...',
|
||||
'atlas.bucketNotesPlaceholder': 'Note (opzionale)',
|
||||
'atlas.bucketEmpty': 'La tua lista desideri è vuota',
|
||||
'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare',
|
||||
@@ -641,6 +731,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.tripPlural': 'Viaggi',
|
||||
'atlas.placeVisited': 'Luogo visitato',
|
||||
'atlas.placesVisited': 'Luoghi visitati',
|
||||
'atlas.searchCountry': 'Cerca un paese...',
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Programma',
|
||||
@@ -663,6 +754,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
||||
'trip.toast.deleted': 'Eliminato',
|
||||
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
||||
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno',
|
||||
@@ -697,10 +789,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Aggiungi Luogo/Attività',
|
||||
'places.importGpx': 'Importa GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||
'places.gpxError': 'Importazione GPX non riuscita',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
|
||||
'places.googleListImported': '{count} luoghi importati da "{list}"',
|
||||
'places.googleListError': 'Importazione lista Google Maps non riuscita',
|
||||
'places.viewDetails': 'Visualizza dettagli',
|
||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||
'places.all': 'Tutti',
|
||||
'places.unplanned': 'Non pianificati',
|
||||
@@ -756,6 +853,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Prenotazione',
|
||||
'inspector.editRes': 'Modifica prenotazione',
|
||||
'inspector.participants': 'Partecipanti',
|
||||
'inspector.trackStats': 'Dati del percorso',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Prenotazioni',
|
||||
@@ -838,6 +936,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'Esporta CSV',
|
||||
'budget.emptyTitle': 'Ancora nessun budget creato',
|
||||
'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio',
|
||||
'budget.emptyPlaceholder': 'Inserisci nome categoria...',
|
||||
@@ -852,6 +951,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Per giorno',
|
||||
'budget.table.perPersonDay': 'P. p / gio.',
|
||||
'budget.table.note': 'Nota',
|
||||
'budget.table.date': 'Data',
|
||||
'budget.newEntry': 'Nuova voce',
|
||||
'budget.defaultEntry': 'Nuova voce',
|
||||
'budget.defaultCategory': 'Nuova categoria',
|
||||
@@ -1245,12 +1345,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'URL Server Immich',
|
||||
'memories.immichApiKey': 'Chiave API',
|
||||
'memories.testConnection': 'Test connessione',
|
||||
'memories.testFirst': 'Testa prima la connessione',
|
||||
'memories.connected': 'Connesso',
|
||||
'memories.disconnected': 'Non connesso',
|
||||
'memories.connectionSuccess': 'Connesso a Immich',
|
||||
'memories.connectionError': 'Impossibile connettersi a Immich',
|
||||
'memories.saved': 'Impostazioni Immich salvate',
|
||||
'memories.addPhotos': 'Aggiungi foto',
|
||||
'memories.linkAlbum': 'Collega album',
|
||||
'memories.selectAlbum': 'Seleziona album Immich',
|
||||
'memories.noAlbums': 'Nessun album trovato',
|
||||
'memories.syncAlbum': 'Sincronizza album',
|
||||
'memories.unlinkAlbum': 'Scollega',
|
||||
'memories.photos': 'foto',
|
||||
'memories.selectPhotos': 'Seleziona foto da Immich',
|
||||
'memories.selectHint': 'Tocca le foto per selezionarle.',
|
||||
'memories.selected': 'selezionate',
|
||||
@@ -1285,6 +1392,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Oggi',
|
||||
'collab.chat.yesterday': 'Ieri',
|
||||
'collab.chat.deletedMessage': 'ha eliminato un messaggio',
|
||||
'collab.chat.reply': 'Rispondi',
|
||||
'collab.chat.loadMore': 'Carica messaggi precedenti',
|
||||
'collab.chat.justNow': 'ora',
|
||||
'collab.chat.minutesAgo': '{n}m fa',
|
||||
@@ -1335,6 +1443,104 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'Opzioni',
|
||||
'collab.polls.delete': 'Elimina',
|
||||
'collab.polls.closedSection': 'Chiusi',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Permessi',
|
||||
'perm.title': 'Impostazioni dei permessi',
|
||||
'perm.subtitle': 'Controlla chi può eseguire azioni nell\'applicazione',
|
||||
'perm.saved': 'Impostazioni dei permessi salvate',
|
||||
'perm.resetDefaults': 'Ripristina predefiniti',
|
||||
'perm.customized': 'personalizzato',
|
||||
'perm.level.admin': 'Solo amministratore',
|
||||
'perm.level.tripOwner': 'Proprietario del viaggio',
|
||||
'perm.level.tripMember': 'Membri del viaggio',
|
||||
'perm.level.everybody': 'Tutti',
|
||||
'perm.cat.trip': 'Gestione viaggi',
|
||||
'perm.cat.members': 'Gestione membri',
|
||||
'perm.cat.files': 'File',
|
||||
'perm.cat.content': 'Contenuti e programma',
|
||||
'perm.cat.extras': 'Budget, bagagli e collaborazione',
|
||||
'perm.action.trip_create': 'Creare viaggi',
|
||||
'perm.action.trip_edit': 'Modificare dettagli del viaggio',
|
||||
'perm.action.trip_delete': 'Eliminare viaggi',
|
||||
'perm.action.trip_archive': 'Archiviare / dearchiviare viaggi',
|
||||
'perm.action.trip_cover_upload': 'Caricare immagine di copertina',
|
||||
'perm.action.member_manage': 'Aggiungere / rimuovere membri',
|
||||
'perm.action.file_upload': 'Caricare file',
|
||||
'perm.action.file_edit': 'Modificare metadati dei file',
|
||||
'perm.action.file_delete': 'Eliminare file',
|
||||
'perm.action.place_edit': 'Aggiungere / modificare / eliminare luoghi',
|
||||
'perm.action.day_edit': 'Modificare giorni, note e assegnazioni',
|
||||
'perm.action.reservation_edit': 'Gestire prenotazioni',
|
||||
'perm.action.budget_edit': 'Gestire budget',
|
||||
'perm.action.packing_edit': 'Gestire liste bagagli',
|
||||
'perm.action.collab_edit': 'Collaborazione (note, sondaggi, chat)',
|
||||
'perm.action.share_manage': 'Gestire link di condivisione',
|
||||
'perm.actionHint.trip_create': 'Chi può creare nuovi viaggi',
|
||||
'perm.actionHint.trip_edit': 'Chi può modificare nome, date, descrizione e valuta del viaggio',
|
||||
'perm.actionHint.trip_delete': 'Chi può eliminare definitivamente un viaggio',
|
||||
'perm.actionHint.trip_archive': 'Chi può archiviare o dearchiviare un viaggio',
|
||||
'perm.actionHint.trip_cover_upload': 'Chi può caricare o modificare l\'immagine di copertina',
|
||||
'perm.actionHint.member_manage': 'Chi può invitare o rimuovere membri del viaggio',
|
||||
'perm.actionHint.file_upload': 'Chi può caricare file in un viaggio',
|
||||
'perm.actionHint.file_edit': 'Chi può modificare descrizioni e link dei file',
|
||||
'perm.actionHint.file_delete': 'Chi può spostare file nel cestino o eliminarli definitivamente',
|
||||
'perm.actionHint.place_edit': 'Chi può aggiungere, modificare o eliminare luoghi',
|
||||
'perm.actionHint.day_edit': 'Chi può modificare giorni, note dei giorni e assegnazioni dei luoghi',
|
||||
'perm.actionHint.reservation_edit': 'Chi può creare, modificare o eliminare prenotazioni',
|
||||
'perm.actionHint.budget_edit': 'Chi può creare, modificare o eliminare voci di budget',
|
||||
'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse',
|
||||
'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi',
|
||||
'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
|
||||
|
||||
// Undo
|
||||
'undo.button': 'Annulla',
|
||||
'undo.tooltip': 'Annulla: {action}',
|
||||
'undo.assignPlace': 'Luogo assegnato al giorno',
|
||||
'undo.removeAssignment': 'Luogo rimosso dal giorno',
|
||||
'undo.reorder': 'Luoghi riordinati',
|
||||
'undo.optimize': 'Percorso ottimizzato',
|
||||
'undo.deletePlace': 'Luogo eliminato',
|
||||
'undo.moveDay': 'Luogo spostato in altro giorno',
|
||||
'undo.lock': 'Blocco luogo modificato',
|
||||
'undo.importGpx': 'Importazione GPX',
|
||||
'undo.importGoogleList': 'Importazione Google Maps',
|
||||
'undo.addPlace': 'Luogo aggiunto',
|
||||
'undo.done': 'Annullato: {action}',
|
||||
// Notifications
|
||||
'notifications.title': 'Notifiche',
|
||||
'notifications.markAllRead': 'Segna tutto come letto',
|
||||
'notifications.deleteAll': 'Elimina tutto',
|
||||
'notifications.showAll': 'Vedi tutte le notifiche',
|
||||
'notifications.empty': 'Nessuna notifica',
|
||||
'notifications.emptyDescription': 'Sei aggiornato!',
|
||||
'notifications.all': 'Tutte',
|
||||
'notifications.unreadOnly': 'Non lette',
|
||||
'notifications.markRead': 'Segna come letto',
|
||||
'notifications.markUnread': 'Segna come non letto',
|
||||
'notifications.delete': 'Elimina',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Caricamento album non riuscito',
|
||||
'memories.error.linkAlbum': 'Collegamento album non riuscito',
|
||||
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
|
||||
'memories.error.syncAlbum': 'Sincronizzazione album non riuscita',
|
||||
'memories.error.loadPhotos': 'Caricamento foto non riuscito',
|
||||
'memories.error.addPhotos': 'Aggiunta foto non riuscita',
|
||||
'memories.error.removePhoto': 'Rimozione foto non riuscita',
|
||||
'memories.error.toggleSharing': 'Aggiornamento condivisione non riuscito',
|
||||
'notifications.test.title': 'Notifica di test da {actor}',
|
||||
'notifications.test.text': 'Questa è una semplice notifica di test.',
|
||||
'notifications.test.booleanTitle': '{actor} richiede la tua approvazione',
|
||||
'notifications.test.booleanText': 'Notifica di test con risposta.',
|
||||
'notifications.test.accept': 'Approva',
|
||||
'notifications.test.decline': 'Rifiuta',
|
||||
'notifications.test.navigateTitle': 'Dai un\'occhiata',
|
||||
'notifications.test.navigateText': 'Notifica di test con navigazione.',
|
||||
'notifications.test.goThere': 'Vai',
|
||||
'notifications.test.adminTitle': 'Comunicazione admin',
|
||||
'notifications.test.adminText': '{actor} ha inviato una notifica di test a tutti gli amministratori.',
|
||||
'notifications.test.tripTitle': '{actor} ha pubblicato nel tuo viaggio',
|
||||
'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".',
|
||||
}
|
||||
|
||||
export default it
|
||||
export default it
|
||||
+1545
-1340
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1545
-1340
File diff suppressed because it is too large
Load Diff
+1545
-1340
File diff suppressed because it is too large
Load Diff
+401
-234
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
@@ -14,7 +16,9 @@ import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
interface AdminUser {
|
||||
@@ -42,6 +46,7 @@ interface OidcConfig {
|
||||
client_secret_set: boolean
|
||||
display_name: string
|
||||
oidc_only: boolean
|
||||
discovery_url: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -56,6 +61,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
|
||||
const devMode = useAuthStore(s => s.devMode)
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
@@ -63,7 +70,9 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>('users')
|
||||
@@ -80,11 +89,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||
|
||||
// Invite links
|
||||
const [invites, setInvites] = useState<any[]>([])
|
||||
@@ -116,13 +126,14 @@ export default function AdminPage(): React.ReactElement {
|
||||
// Version check & update
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
const [updating, setUpdating] = useState<boolean>(false)
|
||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
|
||||
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadAppConfig()
|
||||
@@ -155,6 +166,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
@@ -171,26 +183,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
await adminApi.installUpdate()
|
||||
setUpdateResult('success')
|
||||
// Server is restarting — poll until it comes back, then reload
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
await authApi.getAppConfig()
|
||||
clearInterval(poll)
|
||||
window.location.reload()
|
||||
} catch { /* still restarting */ }
|
||||
}, 2000)
|
||||
} catch {
|
||||
setUpdateResult('error')
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (value) => {
|
||||
setAllowRegistration(value)
|
||||
try {
|
||||
@@ -201,6 +193,18 @@ export default function AdminPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRequireMfa = async (value: boolean) => {
|
||||
setRequireMfa(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ require_mfa: value })
|
||||
setAppRequireMfa(value)
|
||||
toast.success(t('common.saved'))
|
||||
} catch (err: unknown) {
|
||||
setRequireMfa(!value)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
@@ -253,6 +257,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
toast.error(t('admin.toast.fieldsRequired'))
|
||||
return
|
||||
}
|
||||
if (createForm.password.trim().length < 8) {
|
||||
toast.error(t('settings.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await adminApi.createUser(createForm)
|
||||
setUsers(prev => [data.user, ...prev])
|
||||
@@ -308,7 +316,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
email: editForm.email.trim() || undefined,
|
||||
role: editForm.role,
|
||||
}
|
||||
if (editForm.password.trim()) payload.password = editForm.password.trim()
|
||||
if (editForm.password.trim()) {
|
||||
if (editForm.password.trim().length < 8) {
|
||||
toast.error(t('settings.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
payload.password = editForm.password.trim()
|
||||
}
|
||||
const data = await adminApi.updateUser(editingUser.id, payload)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
||||
setEditingUser(null)
|
||||
@@ -376,23 +390,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
{t('admin.update.button')}
|
||||
</a>
|
||||
)}
|
||||
{updateInfo.is_docker ? (
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.howTo')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.install')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.howTo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -618,6 +622,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && <div className="mt-6"><PermissionsPanel /></div>}
|
||||
|
||||
{/* Create Invite Modal */}
|
||||
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
||||
<div className="space-y-4">
|
||||
@@ -692,14 +698,38 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
allowRegistration ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: allowRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require 2FA for all users */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: requireMfa ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -869,6 +899,17 @@ export default function AdminPage(): React.ReactElement {
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label>
|
||||
<input
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={e => setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration'
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
||||
<input
|
||||
@@ -896,14 +937,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
|
||||
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
|
||||
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -912,7 +951,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
setSavingOidc(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url }
|
||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||
await adminApi.updateOidc(payload)
|
||||
toast.success(t('admin.oidcSaved'))
|
||||
@@ -930,49 +969,211 @@ export default function AdminPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* SMTP / Notifications */}
|
||||
{/* Notifications — exclusive channel selector */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && [
|
||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
|
||||
].map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={smtpValues[field.key] || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Channel selector */}
|
||||
<div className="flex gap-2">
|
||||
{(['none', 'email', 'webhook'] as const).map(ch => {
|
||||
const active = (smtpValues.notification_channel || 'none') === ch
|
||||
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
|
||||
return (
|
||||
<button
|
||||
key={ch}
|
||||
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
|
||||
>
|
||||
{labels[ch]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notification event toggles — shown when any channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
|
||||
const ch = smtpValues.notification_channel || 'none'
|
||||
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
||||
return (
|
||||
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
||||
{!configValid && (
|
||||
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
||||
{[
|
||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
||||
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
|
||||
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
|
||||
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
|
||||
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
|
||||
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
|
||||
].map(opt => {
|
||||
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
|
||||
return (
|
||||
<div key={opt.key} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-700">{opt.label}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newVal = isOn ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={async () => {
|
||||
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
||||
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
||||
}
|
||||
try {
|
||||
const result = await notificationsApi.testSmtp()
|
||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Email (SMTP) settings — shown when email channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
|
||||
{smtpLoaded && [
|
||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
].map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={smtpValues[field.key] || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
|
||||
</div>
|
||||
<button onClick={() => {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook settings — shown when webhook channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={smtpValues.notification_webhook_url || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save + Test buttons */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
|
||||
const payload: Record<string, string> = {}
|
||||
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||
try {
|
||||
await authApi.updateAppSettings(payload)
|
||||
toast.success(t('admin.notifications.saved'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{t('common.save')}
|
||||
</button>
|
||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
const payload: Record<string, string> = {}
|
||||
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||
await authApi.updateAppSettings(payload).catch(() => {})
|
||||
try {
|
||||
const result = await notificationsApi.testSmtp()
|
||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||
}}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
)}
|
||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (smtpValues.notification_webhook_url) {
|
||||
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
|
||||
}
|
||||
try {
|
||||
const result = await notificationsApi.testWebhook()
|
||||
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
|
||||
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
|
||||
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
|
||||
}}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{t('admin.notifications.testWebhook')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||
<h2 className="font-semibold text-red-700 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Danger Zone
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRotateJwtModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Rotate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -980,9 +1181,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel />}
|
||||
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||
|
||||
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1122,78 +1327,37 @@ export default function AdminPage(): React.ReactElement {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Update confirmation popup — matches backup restore style */}
|
||||
{/* Update instructions popup */}
|
||||
{showUpdateModal && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => { if (!updating) setShowUpdateModal(false) }}
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{updateResult === 'success' ? (
|
||||
<>
|
||||
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<CheckCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
|
||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
) : updateResult === 'error' ? (
|
||||
<>
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<XCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button
|
||||
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<ArrowUpCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.howTo')}</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
{updateInfo?.is_docker ? (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
|
||||
</p>
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{`docker pull mauriceboe/nomad:latest
|
||||
docker stop nomad && docker rm nomad
|
||||
docker run -d --name nomad \\
|
||||
@@ -1202,90 +1366,93 @@ docker run -d --name nomad \\
|
||||
-v /opt/nomad/uploads:/app/uploads \\
|
||||
--restart unless-stopped \\
|
||||
mauriceboe/nomad:latest`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('admin.update.backupHint')}{' '}
|
||||
<button
|
||||
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
|
||||
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
|
||||
>{t('admin.update.backupLink')}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.warning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
disabled={updating}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!updateInfo?.is_docker && (
|
||||
<button
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={updating}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{updating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
|
||||
</button>
|
||||
)}
|
||||
{updateInfo?.release_url && (
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
|
||||
{t('admin.update.button')}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotate JWT Secret confirmation modal */}
|
||||
<Modal
|
||||
isOpen={showRotateJwtModal}
|
||||
onClose={() => setShowRotateJwtModal(false)}
|
||||
title="Rotate JWT Secret"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowRotateJwtModal(false)}
|
||||
disabled={rotatingJwt}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRotatingJwt(true)
|
||||
try {
|
||||
await adminApi.rotateJwtSecret()
|
||||
setShowRotateJwtModal(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
setRotatingJwt(false)
|
||||
}
|
||||
}}
|
||||
disabled={rotatingJwt}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
|
||||
>
|
||||
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Rotate & Log out
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
|
||||
<p className="text-xs text-slate-500">A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+179
-16
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -127,6 +127,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
|
||||
|
||||
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
@@ -139,7 +140,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
// Border glow that follows cursor
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
const handlePanelMouseLeave = () => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
@@ -170,6 +171,26 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
||||
const bucketMarkersRef = useRef<any>(null)
|
||||
|
||||
const [atlas_country_search, set_atlas_country_search] = useState('')
|
||||
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
|
||||
const [atlas_country_open, set_atlas_country_open] = useState(false)
|
||||
|
||||
const atlas_country_options = useMemo(() => {
|
||||
if (!geoData) return []
|
||||
const opts: { code: string; label: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const f of (geoData as any).features || []) {
|
||||
const a2 = f?.properties?.ISO_A2
|
||||
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
|
||||
if (seen.has(a2)) continue
|
||||
seen.add(a2)
|
||||
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
|
||||
opts.push({ code: a2, label })
|
||||
}
|
||||
opts.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return opts
|
||||
}, [geoData, resolveName])
|
||||
|
||||
// Load atlas data + bucket list
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -231,8 +252,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
updateWhenIdle: false,
|
||||
tileSize: 256,
|
||||
zoomOffset: 0,
|
||||
crossOrigin: true,
|
||||
loading: true,
|
||||
crossOrigin: true
|
||||
}).addTo(map)
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
@@ -292,6 +312,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||
const c = countryMap[a3]
|
||||
if (c) {
|
||||
country_layer_by_a2_ref.current[c.code] = layer
|
||||
const name = resolveName(c.code)
|
||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
||||
const tooltipHtml = `
|
||||
@@ -337,6 +358,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const isoA2 = feature.properties?.ISO_A2
|
||||
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
|
||||
if (countryCode && countryCode !== '-99') {
|
||||
country_layer_by_a2_ref.current[countryCode] = layer
|
||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
@@ -366,6 +388,23 @@ export default function AtlasPage(): React.ReactElement {
|
||||
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
||||
}
|
||||
|
||||
const select_country_from_search = (country_code: string): void => {
|
||||
const country_label = resolveName(country_code)
|
||||
set_atlas_country_search(country_label)
|
||||
set_atlas_country_open(false)
|
||||
set_atlas_country_results([])
|
||||
|
||||
const layer = country_layer_by_a2_ref.current[country_code]
|
||||
try {
|
||||
if (layer?.getBounds && mapInstance.current) {
|
||||
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
|
||||
}
|
||||
} catch (e ) {
|
||||
console.error('Error fitting bounds', e)
|
||||
}
|
||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||
}
|
||||
|
||||
const executeConfirmAction = async (): Promise<void> => {
|
||||
if (!confirmAction) return
|
||||
const { type, code } = confirmAction
|
||||
@@ -494,6 +533,129 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||
{/* Map */}
|
||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 16,
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(18px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<input
|
||||
value={atlas_country_search}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
set_atlas_country_search(raw)
|
||||
const q = raw.trim().toLowerCase()
|
||||
if (!q) {
|
||||
set_atlas_country_results([])
|
||||
set_atlas_country_open(false)
|
||||
return
|
||||
}
|
||||
const results = atlas_country_options
|
||||
.filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q)
|
||||
.slice(0, 8)
|
||||
set_atlas_country_results(results)
|
||||
set_atlas_country_open(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (atlas_country_results.length > 0) set_atlas_country_open(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
set_atlas_country_open(false)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const first = atlas_country_results[0]
|
||||
if (first) select_country_from_search(first.code)
|
||||
}
|
||||
}}
|
||||
placeholder={t('atlas.searchCountry')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: 13,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
{atlas_country_search.trim() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
set_atlas_country_search('')
|
||||
set_atlas_country_results([])
|
||||
set_atlas_country_open(false)
|
||||
}}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atlas_country_open && atlas_country_results.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
|
||||
backdropFilter: 'blur(18px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
onMouseLeave={() => set_atlas_country_open(false)}
|
||||
>
|
||||
{atlas_country_results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
onClick={() => select_country_from_search(r.code)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.label}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Bottom bar */}
|
||||
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
||||
@@ -551,7 +713,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
||||
t={t} dark={dark}
|
||||
@@ -629,24 +791,24 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect
|
||||
value={bucketMonth}
|
||||
value={String(bucketMonth)}
|
||||
onChange={v => setBucketMonth(Number(v))}
|
||||
placeholder={t('atlas.month')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
{ value: '0', label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect
|
||||
value={bucketYear}
|
||||
value={String(bucketYear)}
|
||||
onChange={v => setBucketYear(Number(v))}
|
||||
placeholder={t('atlas.year')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
|
||||
{ value: '0', label: '—' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -717,6 +879,7 @@ interface SidebarContentProps {
|
||||
onSearchBucket: () => Promise<void>
|
||||
onSelectBucketPoi: (result: any) => void
|
||||
bucketSearchResults: any[]
|
||||
setBucketSearchResults: (v: string[]) => void
|
||||
bucketPoiMonth: number
|
||||
setBucketPoiMonth: (v: number) => void
|
||||
bucketPoiYear: number
|
||||
@@ -728,7 +891,7 @@ interface SidebarContentProps {
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
@@ -854,12 +1017,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
{/* Month / Year with CustomSelect */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
<CustomSelect value={String(bucketPoiMonth)} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
|
||||
<CustomSelect value={String(bucketPoiYear)} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -10,12 +10,14 @@ import DemoBanner from '../components/Layout/DemoBanner'
|
||||
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||
LayoutGrid, List,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||
LayoutGrid, List, Copy,
|
||||
} from 'lucide-react'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
|
||||
interface DashboardTrip {
|
||||
id: number
|
||||
@@ -29,6 +31,7 @@ interface DashboardTrip {
|
||||
owner_username?: string
|
||||
day_count?: number
|
||||
place_count?: number
|
||||
shared_count?: number
|
||||
[key: string]: string | number | boolean | null | undefined
|
||||
}
|
||||
|
||||
@@ -56,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
|
||||
|
||||
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
|
||||
@@ -138,16 +141,17 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
|
||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||
interface TripCardProps {
|
||||
trip: DashboardTrip
|
||||
onEdit: (trip: DashboardTrip) => void
|
||||
onDelete: (trip: DashboardTrip) => void
|
||||
onArchive: (id: number) => void
|
||||
onEdit?: (trip: DashboardTrip) => void
|
||||
onCopy?: (trip: DashboardTrip) => void
|
||||
onDelete?: (trip: DashboardTrip) => void
|
||||
onArchive?: (id: number) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
@@ -186,12 +190,15 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
||||
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
|
||||
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
|
||||
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
|
||||
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom content */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||
@@ -220,6 +227,9 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
||||
</div>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +238,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -303,21 +313,25 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
|
||||
</div>
|
||||
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
|
||||
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
|
||||
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List View Item ──────────────────────────────────────────────────────────
|
||||
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -400,14 +414,20 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<MapPin size={11} /> {trip.place_count || 0}
|
||||
</div>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<Users size={11} /> {trip.shared_count+1 || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
|
||||
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label="" />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
|
||||
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -415,23 +435,24 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||
interface ArchivedRowProps {
|
||||
trip: DashboardTrip
|
||||
onEdit: (trip: DashboardTrip) => void
|
||||
onUnarchive: (id: number) => void
|
||||
onDelete: (trip: DashboardTrip) => void
|
||||
onEdit?: (trip: DashboardTrip) => void
|
||||
onCopy?: (trip: DashboardTrip) => void
|
||||
onUnarchive?: (id: number) => void
|
||||
onDelete?: (trip: DashboardTrip) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
function ArchivedRow({ trip, onEdit, onCopy, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
borderRadius: 12, border: '1px solid #f3f4f6', background: 'white', cursor: 'pointer',
|
||||
borderRadius: 12, border: '1px solid var(--border-faint)', background: 'var(--bg-card)', cursor: 'pointer',
|
||||
transition: 'border-color 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-faint)'}>
|
||||
{/* Mini cover */}
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
@@ -440,8 +461,8 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
||||
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
||||
{!trip.is_owner && <span style={{ fontSize: 10, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
||||
</div>
|
||||
{trip.start_date && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
||||
@@ -449,18 +470,25 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(onEdit || onCopy || onUnarchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
||||
{onCopy && <button onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#6b7280' }}>
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<Copy size={12} />
|
||||
</button>}
|
||||
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<ArchiveRestore size={12} /> {t('dashboard.restore')}
|
||||
</button>
|
||||
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#9ca3af' }}
|
||||
</button>}
|
||||
{onDelete && <button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -527,6 +555,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => {
|
||||
@@ -541,6 +570,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const can = useCanDo()
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const showCurrency = settings.dashboard_currency !== 'off'
|
||||
@@ -595,16 +625,18 @@ export default function DashboardPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (trip) => {
|
||||
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
|
||||
const handleDelete = (trip) => setDeleteTrip(trip)
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTrip) return
|
||||
try {
|
||||
await tripsApi.delete(trip.id)
|
||||
setTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
await tripsApi.delete(deleteTrip.id)
|
||||
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
||||
toast.success(t('dashboard.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.deleteError'))
|
||||
}
|
||||
setDeleteTrip(null)
|
||||
}
|
||||
|
||||
const handleArchive = async (id) => {
|
||||
@@ -635,6 +667,16 @@ export default function DashboardPage(): React.ReactElement {
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const handleCopy = async (trip: DashboardTrip) => {
|
||||
try {
|
||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.copied'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.copyError'))
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||
@@ -666,7 +708,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
@@ -681,7 +723,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
@@ -691,7 +733,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
<button
|
||||
{can('trip_create') && <button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||
@@ -703,7 +745,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -768,12 +810,12 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
{t('dashboard.emptyText')}
|
||||
</p>
|
||||
<button
|
||||
{can('trip_create') && <button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={16} /> {t('dashboard.emptyButton')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -782,9 +824,10 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale} dark={dark}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
)}
|
||||
@@ -798,9 +841,10 @@ export default function DashboardPage(): React.ReactElement {
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
@@ -812,9 +856,10 @@ export default function DashboardPage(): React.ReactElement {
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
@@ -842,9 +887,10 @@ export default function DashboardPage(): React.ReactElement {
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onUnarchive={handleUnarchive}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
@@ -893,6 +939,14 @@ export default function DashboardPage(): React.ReactElement {
|
||||
onCoverUpdate={handleCoverUpdate}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTrip}
|
||||
onClose={() => setDeleteTrip(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title={t('common.delete')}
|
||||
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
|
||||
|
||||
export default function InAppNotificationsPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||
const [unreadOnly, setUnreadOnly] = useState(false)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications(true)
|
||||
}, [])
|
||||
|
||||
// Reload when filter changes
|
||||
useEffect(() => {
|
||||
// We need to fetch with the unreadOnly filter — re-fetch from scratch
|
||||
// The store fetchNotifications doesn't take a filter param directly,
|
||||
// so we use the API directly for filtered view via a side channel.
|
||||
// For now, reset and fetch — store always loads all, filter is client-side.
|
||||
fetchNotifications(true)
|
||||
}, [unreadOnly])
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
if (!loaderRef.current) return
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
||||
fetchNotifications(false)
|
||||
}
|
||||
}, { threshold: 0.1 })
|
||||
observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, isLoading])
|
||||
|
||||
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: '#6366f1', color: '#fff' }}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
{total} {total === 1 ? 'notification' : 'notifications'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setUnreadOnly(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: !unreadOnly ? '#6366f1' : 'var(--bg-hover)',
|
||||
color: !unreadOnly ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('notifications.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUnreadOnly(true)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: unreadOnly ? '#6366f1' : 'var(--bg-hover)',
|
||||
color: unreadOnly ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('notifications.unreadOnly')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
|
||||
<Bell className="w-12 h-12" style={{ color: 'var(--text-faint)' }} />
|
||||
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
displayed.map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} />
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
|
||||
|
||||
interface AppConfig {
|
||||
has_users: boolean
|
||||
allow_registration: boolean
|
||||
setup_complete: boolean
|
||||
demo_mode: boolean
|
||||
oidc_configured: boolean
|
||||
oidc_display_name?: string
|
||||
@@ -28,7 +30,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||
|
||||
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
|
||||
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -48,17 +50,16 @@ export default function LoginPage(): React.ReactElement {
|
||||
setError('Invalid or expired invite link')
|
||||
})
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
return
|
||||
}
|
||||
|
||||
if (oidcCode) {
|
||||
setIsLoading(true)
|
||||
window.history.replaceState({}, '', '/login')
|
||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
.then(async data => {
|
||||
if (data.token) {
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
await loadUser()
|
||||
navigate('/dashboard', { replace: true })
|
||||
} else {
|
||||
setError(data.error || 'OIDC login failed')
|
||||
@@ -85,7 +86,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
@@ -110,26 +111,46 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [mfaStep, setMfaStep] = useState(false)
|
||||
const [mfaToken, setMfaToken] = useState('')
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
|
||||
const [savedLoginPassword, setSavedLoginPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (passwordChangeStep) {
|
||||
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
|
||||
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
|
||||
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
|
||||
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
||||
await loadUser({ silent: true })
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'login' && mfaStep) {
|
||||
if (!mfaCode.trim()) {
|
||||
setError(t('login.mfaCodeRequired'))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
await completeMfaLogin(mfaToken, mfaCode)
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||
setSavedLoginPassword(password)
|
||||
setPasswordChangeStep(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'register') {
|
||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return }
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
const result = await login(email, password)
|
||||
@@ -140,16 +161,22 @@ export default function LoginPage(): React.ReactElement {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if ('user' in result && result.user?.must_change_password) {
|
||||
setSavedLoginPassword(password)
|
||||
setPasswordChangeStep(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('login.error'))
|
||||
setError(getApiErrorMessage(err, t('login.error')))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
|
||||
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
|
||||
|
||||
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
||||
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
||||
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
|
||||
) : (
|
||||
<>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||
{mode === 'login' && mfaStep
|
||||
? t('login.mfaTitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
||||
: t('login.title')}
|
||||
{passwordChangeStep
|
||||
? t('login.setNewPassword')
|
||||
: mode === 'login' && mfaStep
|
||||
? t('login.mfaTitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
|
||||
: t('login.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||
{mode === 'login' && mfaStep
|
||||
? t('login.mfaSubtitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
||||
: t('login.subtitle')}
|
||||
{passwordChangeStep
|
||||
? t('login.setNewPasswordHint')
|
||||
: mode === 'login' && mfaStep
|
||||
? t('login.mfaSubtitle')
|
||||
: mode === 'register'
|
||||
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
|
||||
: t('login.subtitle')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
@@ -537,18 +568,50 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'login' && mfaStep && (
|
||||
{passwordChangeStep && (
|
||||
<>
|
||||
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 13, color: '#92400e' }}>
|
||||
{t('settings.mustChangePassword')}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.newPassword')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="password" value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)} required
|
||||
placeholder={t('settings.newPassword')} style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.confirmPassword')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="password" value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)} required
|
||||
placeholder={t('settings.confirmPassword')} style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'login' && mfaStep && !passwordChangeStep && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder="000000"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||
placeholder="000000 or XXXX-XXXX"
|
||||
required
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{/* Username (register only) */}
|
||||
{mode === 'register' && (
|
||||
{mode === 'register' && !passwordChangeStep && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
{!(mode === 'login' && mfaStep) && (
|
||||
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
{!(mode === 'login' && mfaStep) && (
|
||||
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
|
||||
>
|
||||
{isLoading
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
||||
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
|
||||
: <><Plane size={16} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle login/register */}
|
||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 8) {
|
||||
setError(t('register.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
||||
import { authApi, adminApi, notificationsApi } from '../api/client'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Info } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import apiClient from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import type { Place } from '../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
interface McpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
@@ -46,99 +58,112 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
|
||||
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
|
||||
const [addons, setAddons] = useState<Record<string, boolean>>({})
|
||||
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
|
||||
function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
||||
const [notifChannel, setNotifChannel] = useState<string>('none')
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons').then(r => {
|
||||
const map: Record<string, boolean> = {}
|
||||
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
|
||||
setAddons(map)
|
||||
authApi.getAppConfig?.().then((cfg: any) => {
|
||||
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const toggle = async (key: string) => {
|
||||
if (!prefs) return
|
||||
const newVal = prefs[key] ? 0 : 1
|
||||
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
|
||||
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
|
||||
if (notifChannel === 'none') {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationsDisabled')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
|
||||
|
||||
const options = [
|
||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
|
||||
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
|
||||
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
|
||||
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
|
||||
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
|
||||
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
|
||||
]
|
||||
const channelLabel = notifChannel === 'email'
|
||||
? (t('admin.notifications.email') || 'Email (SMTP)')
|
||||
: (t('admin.notifications.webhook') || 'Webhook')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{options.map(opt => (
|
||||
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
|
||||
<button onClick={() => toggle(opt.key)}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||
{t('settings.notificationsActive')}: {channelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
||||
{t('settings.notificationsManagedByAdmin')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Immich
|
||||
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
|
||||
// Addon gating (derived from store)
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
}, [])
|
||||
const [immichUrl, setImmichUrl] = useState('')
|
||||
const [immichApiKey, setImmichApiKey] = useState('')
|
||||
const [immichConnected, setImmichConnected] = useState(false)
|
||||
const [immichTesting, setImmichTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons').then(r => {
|
||||
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
|
||||
setMemoriesEnabled(!!mem)
|
||||
if (mem) {
|
||||
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||
setImmichUrl(r2.data.immich_url || '')
|
||||
setImmichConnected(r2.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}).catch(() => {})
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (memoriesEnabled) {
|
||||
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||
setImmichUrl(r2.data.immich_url || '')
|
||||
setImmichConnected(r2.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [memoriesEnabled])
|
||||
|
||||
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setSaving(s => ({ ...s, immich: true }))
|
||||
try {
|
||||
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
if (saveRes.data.warning) toast.warning(saveRes.data.warning)
|
||||
toast.success(t('memories.saved'))
|
||||
// Test connection
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
setImmichTestPassed(false)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
@@ -149,13 +174,18 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
try {
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||
if (res.data.connected) {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
setImmichConnected(true)
|
||||
if (res.data.canonicalUrl) {
|
||||
setImmichUrl(res.data.canonicalUrl)
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''} (URL updated to ${res.data.canonicalUrl})`)
|
||||
} else {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
}
|
||||
setImmichTestPassed(true)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichConnected(false)
|
||||
setImmichTestPassed(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
@@ -164,12 +194,98 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// MCP tokens
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpNewName, setMcpNewName] = useState('')
|
||||
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleCreateMcpToken = async () => {
|
||||
if (!mcpNewName.trim()) return
|
||||
setMcpCreating(true)
|
||||
try {
|
||||
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||
setMcpCreatedToken(d.token.raw_token)
|
||||
setMcpNewName('')
|
||||
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.createError'))
|
||||
} finally {
|
||||
setMcpCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMcpToken = async (id: number) => {
|
||||
try {
|
||||
await authApi.mcpTokens.delete(id)
|
||||
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setMcpDeleteId(null)
|
||||
toast.success(t('settings.mcp.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--header",
|
||||
"Authorization: Bearer <your_token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// Map settings
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
const mapPlaces = useMemo(() => {
|
||||
// Add center location to map places
|
||||
let places: Place[] = []
|
||||
places.push({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: "Default map center",
|
||||
description: "",
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: "",
|
||||
category_id: 0,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date()
|
||||
});
|
||||
return places
|
||||
}, [defaultLat, defaultLng])
|
||||
|
||||
// Display
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
@@ -193,6 +309,71 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
// Restore backup codes panel after refresh (loadUser silent fix + sessionStorage)
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = (): void => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async (): Promise<void> => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = (): void => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = (): void => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
@@ -288,7 +469,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value=""
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({
|
||||
@@ -331,6 +512,29 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height:"200px", width: "100%" }}>
|
||||
<MapView
|
||||
places={mapPlaces}
|
||||
dayPlaces={[]}
|
||||
route={null}
|
||||
routeSegments={null}
|
||||
selectedPlaceId={null}
|
||||
onMarkerClick={null}
|
||||
onMapClick={handleMapClick}
|
||||
onMapContextMenu={null}
|
||||
center = {[settings.default_lat, settings.default_lng]}
|
||||
zoom={defaultZoom}
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={null}
|
||||
dayOrderMap={[]}
|
||||
leftWidth={0}
|
||||
rightWidth={0}
|
||||
hasInspector={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving.map}
|
||||
@@ -539,19 +743,20 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
||||
<input type="url" value={immichUrl} onChange={e => { setImmichUrl(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder="https://immich.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
||||
<input type="password" value={immichApiKey} onChange={e => { setImmichApiKey(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich || !immichTestPassed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
@@ -572,6 +777,162 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* MCP Configuration — only when MCP addon is enabled */}
|
||||
{mcpEnabled && <Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpEndpoint}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>}
|
||||
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
{!mcpCreatedToken ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setMcpModalOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpCreatedToken}
|
||||
</pre>
|
||||
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setMcpDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.mcp.deleteTokenTitle')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
@@ -629,6 +990,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
@@ -652,6 +1014,19 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div
|
||||
className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
@@ -709,12 +1084,21 @@ export default function SettingsPage(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaEnable({ code: mfaSetupCode })
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
await loadUser()
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try {
|
||||
sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes))
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
@@ -766,7 +1150,9 @@ export default function SettingsPage(): React.ReactElement {
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
await loadUser()
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
@@ -779,6 +1165,29 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -891,6 +1300,24 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{appVersion && (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { getLocaleForLanguage } from '../i18n'
|
||||
import { shareApi } from '../api/client'
|
||||
import { getCategoryIcon } from '../components/shared/categoryIcons'
|
||||
import { createElement } from 'react'
|
||||
@@ -43,7 +44,6 @@ export default function SharedTripPage() {
|
||||
const [error, setError] = useState(false)
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
const { updateSetting } = useSettingsStore()
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,7 +106,7 @@ export default function SharedTripPage() {
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).join(' — ')}
|
||||
</span>
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
|
||||
@@ -127,7 +127,11 @@ export default function SharedTripPage() {
|
||||
{showLangPicker && (
|
||||
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button key={lang.value} onClick={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
|
||||
<button key={lang.value} onClick={() => {
|
||||
// Set language locally without API call (shared page has no auth)
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||
setShowLangPicker(false)
|
||||
}}
|
||||
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
@@ -164,7 +168,7 @@ export default function SharedTripPage() {
|
||||
{activeTab === 'plan' && (<>
|
||||
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
|
||||
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
|
||||
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" referrerPolicy="strict-origin-when-cross-origin" />
|
||||
<FitBoundsToPlaces places={mapPlaces} />
|
||||
{mapPlaces.map((p: any) => (
|
||||
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
|
||||
@@ -195,7 +199,7 @@ export default function SharedTripPage() {
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
|
||||
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{dayAccs.map((acc: any) => (
|
||||
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
@@ -270,7 +274,7 @@ export default function SharedTripPage() {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
|
||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
return (
|
||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
@@ -20,14 +22,15 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
|
||||
|
||||
export default function TripPlannerPage(): React.ReactElement | null {
|
||||
@@ -36,8 +39,28 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const tripStore = useTripStore()
|
||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
const places = useTripStore(s => s.places)
|
||||
const assignments = useTripStore(s => s.assignments)
|
||||
const packingItems = useTripStore(s => s.packingItems)
|
||||
const categories = useTripStore(s => s.categories)
|
||||
const reservations = useTripStore(s => s.reservations)
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const files = useTripStore(s => s.files)
|
||||
const selectedDayId = useTripStore(s => s.selectedDayId)
|
||||
const isLoading = useTripStore(s => s.isLoading)
|
||||
// Actions — stable references, don't cause re-renders
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canUploadFiles = can('file_upload', trip)
|
||||
const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
const label = lastActionLabel
|
||||
await undo()
|
||||
toast.info(t('undo.done', { action: label ?? '' }))
|
||||
}, [undo, lastActionLabel, toast])
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||
@@ -47,7 +70,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const loadAccommodations = useCallback(() => {
|
||||
if (tripId) {
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
tripStore.loadReservations(tripId)
|
||||
tripActions.loadReservations(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
@@ -63,13 +86,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [])
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan') },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') },
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort'), icon: PackageCheck }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
@@ -80,8 +103,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const handleTabChange = (tabId: string): void => {
|
||||
setActiveTab(tabId)
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
|
||||
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
|
||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
|
||||
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
||||
}
|
||||
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||
@@ -98,11 +121,33 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 767px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
// Start photo fetches during splash screen so images are ready when map mounts
|
||||
useEffect(() => {
|
||||
if (isLoading || !places || places.length === 0) return
|
||||
for (const p of places) {
|
||||
if (p.image_url) continue
|
||||
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
||||
if (!cacheKey || getCached(cacheKey)) continue
|
||||
const photoId = p.google_place_id || p.osm_id
|
||||
if (photoId || (p.lat && p.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name)
|
||||
}
|
||||
}
|
||||
}, [isLoading, places])
|
||||
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripStore.loadFiles(tripId)
|
||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripActions.loadFiles(tripId)
|
||||
loadAccommodations()
|
||||
tripsApi.getMembers(tripId).then(d => {
|
||||
// Combine owner + members into one list
|
||||
@@ -113,30 +158,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripStore.loadReservations(tripId)
|
||||
if (tripId) tripActions.loadReservations(tripId)
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
||||
|
||||
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
||||
|
||||
const mapPlaces = useMemo(() => {
|
||||
// Build set of place IDs assigned to collapsed days
|
||||
const hiddenPlaceIds = new Set<number>()
|
||||
if (expandedDayIds) {
|
||||
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||
if (!expandedDayIds.has(Number(dayId))) {
|
||||
for (const a of dayAssignments) {
|
||||
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Don't hide places that are also assigned to an expanded day
|
||||
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||
if (expandedDayIds.has(Number(dayId))) {
|
||||
for (const a of dayAssignments) {
|
||||
hiddenPlaceIds.delete(a.place?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
||||
if (hiddenPlaceIds.has(p.id)) return false
|
||||
return true
|
||||
})
|
||||
}, [places, mapCategoryFilter])
|
||||
}, [places, mapCategoryFilter, assignments, expandedDayIds])
|
||||
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
||||
|
||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||
const changed = dayId !== selectedDayId
|
||||
tripStore.setSelectedDay(dayId)
|
||||
tripActions.setSelectedDay(dayId)
|
||||
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||
setMobileSidebarOpen(null)
|
||||
updateRouteForDay(dayId)
|
||||
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||
}, [updateRouteForDay, selectedDayId])
|
||||
|
||||
const handlePlaceClick = useCallback((placeId, assignmentId) => {
|
||||
if (assignmentId) {
|
||||
@@ -158,6 +226,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [])
|
||||
|
||||
const handleMapContextMenu = useCallback(async (e) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
e.originalEvent?.preventDefault()
|
||||
const { lat, lng } = e.latlng
|
||||
setPrefillCoords({ lat, lng })
|
||||
@@ -179,11 +248,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
if (editingPlace) {
|
||||
// Always strip time fields from place update — time is per-assignment only
|
||||
const { place_time, end_time, ...placeData } = data
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
|
||||
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
|
||||
// If editing from assignment context, save time per-assignment
|
||||
if (editingAssignmentId) {
|
||||
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
||||
await tripStore.refreshDays(tripId)
|
||||
await tripActions.refreshDays(tripId)
|
||||
}
|
||||
// Upload pending files with place_id
|
||||
if (pendingFiles?.length > 0) {
|
||||
@@ -191,23 +260,29 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', editingPlace.id)
|
||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||
try { await tripActions.addFile(tripId, fd) } catch {}
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
} else {
|
||||
const place = await tripStore.addPlace(tripId, data)
|
||||
const place = await tripActions.addPlace(tripId, data)
|
||||
if (pendingFiles?.length > 0 && place?.id) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', place.id)
|
||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||
try { await tripActions.addFile(tripId, fd) } catch {}
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
if (place?.id) {
|
||||
const capturedId = place.id
|
||||
pushUndo(t('undo.addPlace'), async () => {
|
||||
await tripActions.deletePlace(tripId, capturedId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
|
||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
@@ -215,35 +290,85 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
const confirmDeletePlace = useCallback(async () => {
|
||||
if (!deletePlaceId) return
|
||||
const state = useTripStore.getState()
|
||||
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
|
||||
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
||||
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
|
||||
)
|
||||
try {
|
||||
await tripStore.deletePlace(tripId, deletePlaceId)
|
||||
await tripActions.deletePlace(tripId, deletePlaceId)
|
||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
if (capturedPlace) {
|
||||
pushUndo(t('undo.deletePlace'), async () => {
|
||||
const newPlace = await tripActions.addPlace(tripId, {
|
||||
name: capturedPlace.name,
|
||||
description: capturedPlace.description,
|
||||
lat: capturedPlace.lat,
|
||||
lng: capturedPlace.lng,
|
||||
address: capturedPlace.address,
|
||||
category_id: capturedPlace.category_id,
|
||||
icon: capturedPlace.icon,
|
||||
price: capturedPlace.price,
|
||||
})
|
||||
for (const { dayId, orderIndex } of capturedAssignments) {
|
||||
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
try {
|
||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
updateRouteForDay(target)
|
||||
if (assignment?.id) {
|
||||
const capturedAssignmentId = assignment.id
|
||||
const capturedTarget = target
|
||||
pushUndo(t('undo.assignPlace'), async () => {
|
||||
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||
const state = useTripStore.getState()
|
||||
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
|
||||
const capturedPlaceId = capturedAssignment?.place?.id
|
||||
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
||||
try {
|
||||
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||
if (capturedPlaceId != null) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPos = capturedOrderIndex
|
||||
pushUndo(t('undo.removeAssignment'), async () => {
|
||||
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||
}, [tripId, toast, updateRouteForDay, pushUndo])
|
||||
|
||||
const handleReorder = useCallback((dayId, orderedIds) => {
|
||||
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
|
||||
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
try {
|
||||
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
|
||||
tripActions.reorderAssignments(tripId, dayId, orderedIds)
|
||||
.then(() => {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevIds
|
||||
pushUndo(t('undo.reorder'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
// Update route immediately from orderedIds
|
||||
const dayItems = tripStore.assignments[String(dayId)] || []
|
||||
const dayItems = useTripStore.getState().assignments[String(dayId)] || []
|
||||
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||
@@ -251,17 +376,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
setRouteInfo(null)
|
||||
}
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, tripStore, toast])
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripStore.updateDayTitle(tripId, dayId, title) }
|
||||
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [tripId, tripStore, toast])
|
||||
}, [tripId, toast])
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
if (data.type === 'hotel') {
|
||||
@@ -269,7 +394,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}
|
||||
return r
|
||||
} else {
|
||||
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowReservationModal(false)
|
||||
// Refresh accommodations if hotel was created
|
||||
@@ -283,7 +408,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
await tripActions.deleteReservation(tripId, id)
|
||||
toast.success(t('trip.toast.deleted'))
|
||||
// Refresh accommodations in case a hotel booking was deleted
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
@@ -320,12 +445,53 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
||||
|
||||
if (isLoading) {
|
||||
// Splash screen — show for initial load + a brief moment for photos to start loading
|
||||
const [splashDone, setSplashDone] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isLoading && trip) {
|
||||
const timer = setTimeout(() => setSplashDone(true), 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isLoading, trip])
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, border: '3px solid rgba(0,0,0,0.1)', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--bg-primary)', ...fontStyle,
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes planeFloat {
|
||||
0%, 100% { transform: translateY(0px) rotate(-2deg); }
|
||||
50% { transform: translateY(-12px) rotate(2deg); }
|
||||
}
|
||||
@keyframes dotPulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ animation: 'planeFloat 2.5s ease-in-out infinite', marginBottom: 28 }}>
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.8 }}>
|
||||
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.3px', marginBottom: 6, animation: 'fadeInUp 0.5s ease-out' }}>
|
||||
{trip?.title || 'TREK'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontWeight: 500, letterSpacing: '2px', textTransform: 'uppercase', marginBottom: 32, animation: 'fadeInUp 0.5s ease-out 0.1s both' }}>
|
||||
{t('trip.loadingPhotos')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: 'var(--text-muted)',
|
||||
animation: `dotPulse 1.4s ease-in-out ${i * 0.2}s infinite`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -350,10 +516,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}}>
|
||||
{TRIP_TABS.map(tab => {
|
||||
const isActive = activeTab === tab.id
|
||||
const TabIcon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
title={tab.label}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
@@ -361,13 +529,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontFamily: 'inherit', transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
|
||||
>{tab.shortLabel
|
||||
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
|
||||
: tab.label
|
||||
}</button>
|
||||
>
|
||||
{TabIcon && <><TabIcon size={20} className="sm:hidden" /><TabIcon size={15} className="hidden sm:block" /></>}
|
||||
<span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -440,13 +609,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
reservations={reservations}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
onExpandedDaysChange={setExpandedDayIds}
|
||||
pushUndo={pushUndo}
|
||||
canUndo={canUndo}
|
||||
lastActionLabel={lastActionLabel}
|
||||
onUndo={handleUndo}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
@@ -507,6 +681,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
pushUndo={pushUndo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,15 +717,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
reservations={reservations}
|
||||
lat={geoPlace?.lat}
|
||||
lng={geoPlace?.lng}
|
||||
onClose={() => setShowDayDetail(null)}
|
||||
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
|
||||
onAccommodationChange={loadAccommodations}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedPlace && (
|
||||
{selectedPlace && !isMobile && (
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
@@ -561,7 +736,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
// When editing from assignment context, use assignment-level times
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
@@ -576,7 +750,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||
tripMembers={tripMembers}
|
||||
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||
try {
|
||||
@@ -591,12 +765,64 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}))
|
||||
} catch {}
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPlace && isMobile && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => {
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
setSelectedPlaceId(null)
|
||||
}}
|
||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||
tripMembers={tripMembers}
|
||||
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||
try {
|
||||
const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
|
||||
useTripStore.setState(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
|
||||
a.id === assignmentId ? { ...a, participants: data.participants } : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
} catch {}
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||
leftWidth={0}
|
||||
rightWidth={0}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{mobileSidebarOpen && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
|
||||
<div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
||||
@@ -608,8 +834,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} pushUndo={pushUndo} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -651,9 +877,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||
<FileManager
|
||||
files={files || []}
|
||||
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
onDelete={(id) => tripStore.deleteFile(tripId, id)}
|
||||
onUpdate={(id, data) => tripStore.loadFiles(tripId)}
|
||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||
onDelete={(id) => tripActions.deleteFile(tripId, id)}
|
||||
onUpdate={(id, data) => tripActions.loadFiles(tripId)}
|
||||
places={places}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
@@ -677,10 +903,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
@@ -41,11 +41,16 @@ export default function VacayPage(): React.ReactElement {
|
||||
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
|
||||
}, [selectedYear])
|
||||
|
||||
const handleAddYear = () => {
|
||||
const handleAddNextYear = () => {
|
||||
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
|
||||
addYear(nextYear)
|
||||
}
|
||||
|
||||
const handleAddPrevYear = () => {
|
||||
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
|
||||
addYear(prevYear)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement {
|
||||
<>
|
||||
{/* Year Selector */}
|
||||
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
|
||||
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{years.map(y => (
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mapsApi } from '../api/client'
|
||||
|
||||
// Shared photo cache — used by PlaceAvatar (sidebar) and MapView (map markers)
|
||||
interface PhotoEntry {
|
||||
photoUrl: string | null
|
||||
thumbDataUrl: string | null
|
||||
}
|
||||
|
||||
const cache = new Map<string, PhotoEntry>()
|
||||
const inFlight = new Set<string>()
|
||||
const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
|
||||
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
|
||||
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
|
||||
|
||||
function notify(key: string, entry: PhotoEntry) {
|
||||
listeners.get(key)?.forEach(fn => fn(entry))
|
||||
listeners.delete(key)
|
||||
}
|
||||
|
||||
function notifyThumb(key: string, thumb: string) {
|
||||
thumbListeners.get(key)?.forEach(fn => fn(thumb))
|
||||
thumbListeners.delete(key)
|
||||
}
|
||||
|
||||
export function onPhotoLoaded(key: string, fn: (entry: PhotoEntry) => void): () => void {
|
||||
if (!listeners.has(key)) listeners.set(key, new Set())
|
||||
listeners.get(key)!.add(fn)
|
||||
return () => { listeners.get(key)?.delete(fn) }
|
||||
}
|
||||
|
||||
// Subscribe to thumb availability — called when base64 thumb is ready (may be after photoUrl)
|
||||
export function onThumbReady(key: string, fn: (thumb: string) => void): () => void {
|
||||
if (!thumbListeners.has(key)) thumbListeners.set(key, new Set())
|
||||
thumbListeners.get(key)!.add(fn)
|
||||
return () => { thumbListeners.get(key)?.delete(fn) }
|
||||
}
|
||||
|
||||
export function getCached(key: string): PhotoEntry | undefined {
|
||||
return cache.get(key)
|
||||
}
|
||||
|
||||
export function isLoading(key: string): boolean {
|
||||
return inFlight.has(key)
|
||||
}
|
||||
|
||||
// Convert image URL to base64 via canvas (CORS required — Wikimedia supports it)
|
||||
export function urlToBase64(url: string, size: number = 48): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const s = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
const sx = (img.naturalWidth - s) / 2
|
||||
const sy = (img.naturalHeight - s) / 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size)
|
||||
resolve(canvas.toDataURL('image/webp', 0.6))
|
||||
} catch { resolve(null) }
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchPhoto(
|
||||
cacheKey: string,
|
||||
photoId: string,
|
||||
lat?: number,
|
||||
lng?: number,
|
||||
name?: string,
|
||||
callback?: (entry: PhotoEntry) => void
|
||||
) {
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached) { callback?.(cached); return }
|
||||
|
||||
if (inFlight.has(cacheKey)) {
|
||||
if (callback) onPhotoLoaded(cacheKey, callback)
|
||||
return
|
||||
}
|
||||
|
||||
inFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey) })
|
||||
}
|
||||
|
||||
export function getAllThumbs(): Record<string, string> {
|
||||
const r: Record<string, string> = {}
|
||||
for (const [k, v] of cache.entries()) {
|
||||
if (v.thumbDataUrl) r[k] = v.thumbDataUrl
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand'
|
||||
import { addonsApi } from '../api/client'
|
||||
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AddonState {
|
||||
addons: Addon[]
|
||||
loaded: boolean
|
||||
loadAddons: () => Promise<void>
|
||||
isEnabled: (id: string) => boolean
|
||||
}
|
||||
|
||||
export const useAddonStore = create<AddonState>((set, get) => ({
|
||||
addons: [],
|
||||
loaded: false,
|
||||
|
||||
loadAddons: async () => {
|
||||
try {
|
||||
const data = await addonsApi.enabled()
|
||||
set({ addons: data.addons || [], loaded: true })
|
||||
} catch {
|
||||
set({ loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
isEnabled: (id: string) => {
|
||||
return get().addons.some(a => a.id === id && a.enabled)
|
||||
},
|
||||
}))
|
||||
@@ -17,41 +17,54 @@ interface AvatarResponse {
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
devMode: boolean
|
||||
hasMapsKey: boolean
|
||||
serverTimezone: string
|
||||
/** Server policy: all users must enable MFA */
|
||||
appRequireMfa: boolean
|
||||
tripRemindersEnabled: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
loadUser: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
|
||||
updateProfile: (profileData: Partial<User>) => Promise<void>
|
||||
uploadAvatar: (file: File) => Promise<AvatarResponse>
|
||||
deleteAvatar: () => Promise<void>
|
||||
setDemoMode: (val: boolean) => void
|
||||
setDevMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
setServerTimezone: (tz: string) => void
|
||||
setAppRequireMfa: (val: boolean) => void
|
||||
setTripRemindersEnabled: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
let authSequence = 0
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('auth_token') || null,
|
||||
isAuthenticated: !!localStorage.getItem('auth_token'),
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
devMode: false,
|
||||
hasMapsKey: false,
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
@@ -59,15 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ isLoading: false, error: null })
|
||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||
}
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
connect()
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
@@ -77,18 +88,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
connect()
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
@@ -98,18 +108,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
register: async (username: string, email: string, password: string, invite_token?: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.register({ username, email, password, invite_token })
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
@@ -120,38 +129,47 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
localStorage.removeItem('auth_token')
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
if ('caches' in window) {
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
}
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
const token = get().token
|
||||
if (!token) {
|
||||
set({ isLoading: false })
|
||||
return
|
||||
}
|
||||
set({ isLoading: true })
|
||||
loadUser: async (opts?: { silent?: boolean }) => {
|
||||
const seq = authSequence
|
||||
const silent = !!opts?.silent
|
||||
if (!silent) set({ isLoading: true })
|
||||
try {
|
||||
const data = await authApi.me()
|
||||
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
connect(token)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||
(err as { response?: { status?: number } }).response?.status === 401
|
||||
if (isAuthError) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
} else {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -203,23 +221,25 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ demoMode: val })
|
||||
},
|
||||
|
||||
setDevMode: (val: boolean) => set({ devMode: val }),
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.demoLogin()
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Demo login failed')
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { create } from 'zustand'
|
||||
import { inAppNotificationsApi } from '../api/client'
|
||||
|
||||
export interface InAppNotification {
|
||||
id: number
|
||||
type: 'simple' | 'boolean' | 'navigate'
|
||||
scope: 'trip' | 'user' | 'admin'
|
||||
target: number
|
||||
sender_id: number | null
|
||||
sender_username: string | null
|
||||
sender_avatar: string | null
|
||||
recipient_id: number
|
||||
title_key: string
|
||||
title_params: Record<string, string>
|
||||
text_key: string
|
||||
text_params: Record<string, string>
|
||||
positive_text_key: string | null
|
||||
negative_text_key: string | null
|
||||
response: 'positive' | 'negative' | null
|
||||
navigate_text_key: string | null
|
||||
navigate_target: string | null
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface RawNotification extends Omit<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
|
||||
title_params: string | Record<string, string>
|
||||
text_params: string | Record<string, string>
|
||||
is_read: number | boolean
|
||||
}
|
||||
|
||||
function normalizeNotification(raw: RawNotification): InAppNotification {
|
||||
return {
|
||||
...raw,
|
||||
title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params,
|
||||
text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params,
|
||||
is_read: Boolean(raw.is_read),
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: InAppNotification[]
|
||||
unreadCount: number
|
||||
total: number
|
||||
isLoading: boolean
|
||||
hasMore: boolean
|
||||
|
||||
fetchNotifications: (reset?: boolean) => Promise<void>
|
||||
fetchUnreadCount: () => Promise<void>
|
||||
markRead: (id: number) => Promise<void>
|
||||
markUnread: (id: number) => Promise<void>
|
||||
markAllRead: () => Promise<void>
|
||||
deleteNotification: (id: number) => Promise<void>
|
||||
deleteAll: () => Promise<void>
|
||||
respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>
|
||||
|
||||
handleNewNotification: (notification: RawNotification) => void
|
||||
handleUpdatedNotification: (notification: RawNotification) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const useInAppNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
hasMore: false,
|
||||
|
||||
fetchNotifications: async (reset = false) => {
|
||||
const { notifications, isLoading } = get()
|
||||
if (isLoading) return
|
||||
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const offset = reset ? 0 : notifications.length
|
||||
const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
|
||||
const normalized = (data.notifications as RawNotification[]).map(normalizeNotification)
|
||||
|
||||
set({
|
||||
notifications: reset ? normalized : [...notifications, ...normalized],
|
||||
total: data.total,
|
||||
unreadCount: data.unread_count,
|
||||
hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const data = await inAppNotificationsApi.unreadCount()
|
||||
set({ unreadCount: data.count })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async (id: number) => {
|
||||
try {
|
||||
await inAppNotificationsApi.markRead(id)
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n),
|
||||
unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)),
|
||||
}))
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
markUnread: async (id: number) => {
|
||||
try {
|
||||
await inAppNotificationsApi.markUnread(id)
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n),
|
||||
unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0),
|
||||
}))
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead: async () => {
|
||||
try {
|
||||
await inAppNotificationsApi.markAllRead()
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, is_read: true })),
|
||||
unreadCount: 0,
|
||||
}))
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
deleteNotification: async (id: number) => {
|
||||
const notification = get().notifications.find(n => n.id === id)
|
||||
try {
|
||||
await inAppNotificationsApi.delete(id)
|
||||
set(state => ({
|
||||
notifications: state.notifications.filter(n => n.id !== id),
|
||||
total: Math.max(0, state.total - 1),
|
||||
unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||
}))
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
deleteAll: async () => {
|
||||
try {
|
||||
await inAppNotificationsApi.deleteAll()
|
||||
set({ notifications: [], total: 0, unreadCount: 0, hasMore: false })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
respondToBoolean: async (id: number, response: 'positive' | 'negative') => {
|
||||
try {
|
||||
const data = await inAppNotificationsApi.respond(id, response)
|
||||
if (data.notification) {
|
||||
const normalized = normalizeNotification(data.notification as RawNotification)
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => n.id === id ? normalized : n),
|
||||
unreadCount: !state.notifications.find(n => n.id === id)?.is_read
|
||||
? Math.max(0, state.unreadCount - 1)
|
||||
: state.unreadCount,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
|
||||
handleNewNotification: (raw: RawNotification) => {
|
||||
const notification = normalizeNotification(raw)
|
||||
set(state => ({
|
||||
notifications: [notification, ...state.notifications],
|
||||
total: state.total + 1,
|
||||
unreadCount: state.unreadCount + 1,
|
||||
}))
|
||||
},
|
||||
|
||||
handleUpdatedNotification: (raw: RawNotification) => {
|
||||
const notification = normalizeNotification(raw)
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => n.id === notification.id ? notification : n),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from 'zustand'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody'
|
||||
|
||||
/** Minimal trip shape used by permission checks — accepts both Trip and DashboardTrip */
|
||||
type TripOwnerContext = { user_id?: unknown; owner_id?: unknown; is_owner?: unknown }
|
||||
|
||||
interface PermissionsState {
|
||||
permissions: Record<string, PermissionLevel>
|
||||
setPermissions: (perms: Record<string, PermissionLevel>) => void
|
||||
}
|
||||
|
||||
export const usePermissionsStore = create<PermissionsState>((set) => ({
|
||||
permissions: {},
|
||||
setPermissions: (perms) => set({ permissions: perms }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Hook that returns a permission checker bound to the current user.
|
||||
* Usage: const can = useCanDo(); can('trip_create') or can('file_upload', trip)
|
||||
*/
|
||||
export function useCanDo() {
|
||||
const perms = usePermissionsStore((s: PermissionsState) => s.permissions)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
return function can(
|
||||
actionKey: string,
|
||||
trip?: TripOwnerContext | null,
|
||||
): boolean {
|
||||
if (!user) return false
|
||||
if (user.role === 'admin') return true
|
||||
|
||||
const level = perms[actionKey]
|
||||
if (!level) return true // not configured = allow
|
||||
|
||||
// Support both Trip (owner_id) and DashboardTrip/server response (user_id)
|
||||
const tripOwnerId = (trip?.user_id as number | undefined) ?? (trip?.owner_id as number | undefined) ?? null
|
||||
const isOwnerFlag = trip?.is_owner === true || trip?.is_owner === 1
|
||||
const isOwner = isOwnerFlag || (tripOwnerId !== null && tripOwnerId === user.id)
|
||||
const isMember = !isOwner && trip != null
|
||||
|
||||
switch (level) {
|
||||
case 'admin': return false
|
||||
case 'trip_owner': return isOwner
|
||||
case 'trip_member': return isOwner || isMember
|
||||
case 'everybody': return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,15 +37,22 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
set(state => ({
|
||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id === placeId)) {
|
||||
updatedAssignments[dayId] = items.map((a: Assignment) =>
|
||||
a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating place'))
|
||||
@@ -55,15 +62,20 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
set(state => ({
|
||||
places: state.places.filter(p => p.id !== placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter((a: Assignment) => a.place?.id !== placeId)
|
||||
])
|
||||
),
|
||||
}))
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id === placeId)) {
|
||||
updatedAssignments[dayId] = items.filter((a: Assignment) => a.place?.id !== placeId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== placeId),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||
}
|
||||
|
||||
@@ -222,7 +222,14 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
||||
|
||||
removeYear: async (year: number) => {
|
||||
const data = await api.removeYear(year)
|
||||
set({ years: data.years })
|
||||
const updates: Partial<VacayState> = { years: data.years }
|
||||
if (get().selectedYear === year) {
|
||||
updates.selectedYear = data.years.length > 0
|
||||
? data.years[data.years.length - 1]
|
||||
: new Date().getFullYear()
|
||||
}
|
||||
set(updates)
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
loadEntries: async (year?: number) => {
|
||||
@@ -240,6 +247,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
||||
toggleCompanyHoliday: async (date: string) => {
|
||||
await api.toggleCompanyHoliday(date)
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
loadStats: async (year?: number) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user