mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b476f011 | |||
| 959d6c3714 | |||
| c37ee2c6c3 | |||
| 0175a06c9e | |||
| 39113e12de | |||
| d02ecf239e | |||
| 8691814330 | |||
| 48098ef5ec | |||
| c565f22bf2 | |||
| 5bf8dd8cef | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/dist
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ body:
|
|||||||
- Docker (standalone)
|
- Docker (standalone)
|
||||||
- Kubernetes / Helm
|
- Kubernetes / Helm
|
||||||
- Unraid template
|
- Unraid template
|
||||||
|
- Proxmox Community Script
|
||||||
- Sources
|
- Sources
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
## Checklist
|
## Checklist
|
||||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||||
- [ ] This PR targets the `dev` branch, not `main`
|
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||||
- [ ] I have tested my changes locally
|
- [ ] I have tested my changes locally
|
||||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||||
- [ ] I have updated documentation if needed
|
- [ ] I have updated documentation if needed
|
||||||
|
|||||||
@@ -26,9 +26,36 @@ jobs:
|
|||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
for (const pull of pulls) {
|
for (const pull of pulls) {
|
||||||
|
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||||
|
if (hasBypass) continue;
|
||||||
|
|
||||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
if (!hasLabel) continue;
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt — clear label and skip
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull.number,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const createdAt = new Date(pull.created_at);
|
const createdAt = new Date(pull.created_at);
|
||||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'wiki/**'
|
- 'wiki/**'
|
||||||
- '.github/workflows/wiki.yml'
|
- '.github/workflows/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump:
|
bump:
|
||||||
@@ -99,16 +102,15 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update package.json files and Helm chart
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
|
||||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
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 charts/trek/Chart.yaml
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
|
|||||||
@@ -21,6 +21,39 @@ jobs:
|
|||||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||||
const prNumber = context.payload.pull_request.number;
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// bypass-branch-check label skips all enforcement
|
||||||
|
if (labels.includes('bypass-branch-check')) {
|
||||||
|
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt from branch enforcement
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the base was fixed, remove the label and let it through
|
// If the base was fixed, remove the label and let it through
|
||||||
if (base !== 'main') {
|
if (base !== 'main') {
|
||||||
if (labels.includes('wrong-base-branch')) {
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Lint & Prettier
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run lint & format check
|
||||||
|
id: checks
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
cd shared
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
|
||||||
|
- name: Comment on PR if checks failed
|
||||||
|
if: steps.checks.outcome == 'failure'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: [
|
||||||
|
'## ❌ Lint & Prettier check failed',
|
||||||
|
'',
|
||||||
|
'Please fix the issues locally by running the following commands inside the `shared` package:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'cd shared',
|
||||||
|
'npm run lint',
|
||||||
|
'npm run format',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'Then commit and push the changes.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Fail the job if checks failed
|
||||||
|
if: steps.checks.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
@@ -8,10 +8,33 @@ on:
|
|||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
name: Server Tests
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,12 +44,33 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd server && npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||||
|
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||||
|
# optional native binary, so npm ci/install skips it on the runner.
|
||||||
|
# Install the matching version explicitly so the server's SWC transform
|
||||||
|
# (server/vitest.config.ts) can load.
|
||||||
|
run: |
|
||||||
|
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||||
|
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: cd server && npm run lint:check
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd server && npm run test:coverage
|
run: cd server && npm run test:coverage
|
||||||
@@ -48,12 +92,24 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd client && npm ci
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd client && npm run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: cd client && npm run lint:check
|
||||||
|
|
||||||
|
- name: Page pattern check
|
||||||
|
run: cd client && npm run lint:pages
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
run: cd client && npm run test:coverage
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Publish to GitHub wiki
|
- name: Publish to GitHub wiki
|
||||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
with:
|
with:
|
||||||
strategy: init
|
strategy: clone
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
|||||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). 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
|
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||||
|
|||||||
+52
-19
@@ -1,28 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
RUN npm ci --workspace=shared
|
||||||
RUN npm run build
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine AS client-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
COPY shared/package.json ./shared/
|
||||||
npm ci --production && \
|
COPY server/package.json ./server/
|
||||||
apk del python3 make g++
|
|
||||||
|
|
||||||
COPY server/ ./
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
npm ci --workspace=server --omit=dev && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
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 && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
|
ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -36,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
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"]
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
@@ -127,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
- **Dashboard widgets** — currency converter and timezone clocks
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🤖 AI / MCP
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
@@ -152,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -172,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
|||||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -338,7 +342,8 @@ server {
|
|||||||
ssl_certificate /etc/ssl/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 50m;
|
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -355,6 +360,7 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -394,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Email: **report@liketrek.com**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Trademark Policy
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||||
|
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||||
|
license does not include a license to use our trademarks.
|
||||||
|
|
||||||
|
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||||
|
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||||
|
indicators of the software we release; and 2) our community members' desire to
|
||||||
|
be full participants in the TREK project.
|
||||||
|
|
||||||
|
## Our trademarks
|
||||||
|
|
||||||
|
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||||
|
goodwill, or designs (our "Marks").
|
||||||
|
|
||||||
|
## In general
|
||||||
|
|
||||||
|
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||||
|
anyone about exactly who is the source of the software. For example, you cannot
|
||||||
|
say you are distributing TREK when you're distributing a modified version of it,
|
||||||
|
because people would think they would be getting the same software that they
|
||||||
|
can get directly from us when they aren't. You also cannot use our Marks on
|
||||||
|
your website in a way that suggests that your website is an official TREK
|
||||||
|
website or that we endorse your website. But, if true, you can say you like
|
||||||
|
TREK, that you participate in the TREK community, that you are providing an
|
||||||
|
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||||
|
TREK.
|
||||||
|
|
||||||
|
This fundamental requirement, that it is always clear to people what they are
|
||||||
|
getting and from whom, is reflected throughout this policy. It should also
|
||||||
|
serve as your guide if you are not sure about how you are using the Marks.
|
||||||
|
|
||||||
|
In addition:
|
||||||
|
|
||||||
|
* You may not use or register, in whole or in part, the Marks as part of your
|
||||||
|
own trademark, service mark, domain name, company name, trade name, product
|
||||||
|
name or service name.
|
||||||
|
* Trademark law does not allow your use of names or trademarks that are too
|
||||||
|
similar to ours. You therefore may not use an obvious variation of any of our
|
||||||
|
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||||
|
abbreviation for a similar or compatible product or service.
|
||||||
|
* You agree that you will not acquire any rights in the Marks and that any
|
||||||
|
goodwill generated by your use of the Marks and participation in our
|
||||||
|
community inures solely to our benefit.
|
||||||
|
|
||||||
|
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||||
|
|
||||||
|
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||||
|
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||||
|
the software to identify your redistribution. This kind of use only applies if
|
||||||
|
you are redistributing an official TREK distribution that has not been changed
|
||||||
|
in any way.
|
||||||
|
|
||||||
|
## Distribution of executable code that you have compiled, or modified code
|
||||||
|
|
||||||
|
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||||
|
describe the origin of the software that you are providing, that is, that the
|
||||||
|
code you are distributing is a modification of TREK. You may say, for example,
|
||||||
|
that "this software is derived from the source code for TREK."
|
||||||
|
|
||||||
|
Of course, you can place your own trademarks or logos on versions of the
|
||||||
|
software to which you have made substantive modifications, because by modifying
|
||||||
|
the software, you have become the origin of that exact version. In that case,
|
||||||
|
you should not use our Marks.
|
||||||
|
|
||||||
|
However, you may use our Marks for the distribution of code (source or
|
||||||
|
executable) on the condition that any executable is built from an official TREK
|
||||||
|
source code release and that any modifications are limited to switching on or
|
||||||
|
off features already included in the software, translations into other
|
||||||
|
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||||
|
further modification is not permitted.
|
||||||
|
|
||||||
|
## Mobile wrappers, hosted instances, and forks
|
||||||
|
|
||||||
|
The following clarifications apply specifically to common ways TREK is
|
||||||
|
redistributed:
|
||||||
|
|
||||||
|
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||||
|
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||||
|
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||||
|
Official," etc.).
|
||||||
|
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||||
|
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||||
|
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||||
|
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||||
|
unless your wrapper distributes only an unmodified, official TREK instance
|
||||||
|
and you have obtained permission.
|
||||||
|
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||||
|
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||||
|
of TREK," but the project name itself must be your own.
|
||||||
|
|
||||||
|
## Statements about your software's relation to TREK
|
||||||
|
|
||||||
|
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||||
|
relationship between your software and ours. The word mark "TREK" should be
|
||||||
|
used after a verb or preposition that describes the relationship between your
|
||||||
|
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||||
|
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||||
|
|
||||||
|
* [Your software] uses TREK
|
||||||
|
* [Your software] is powered by TREK
|
||||||
|
* [Your software] runs on TREK
|
||||||
|
* [Your software] for use with TREK
|
||||||
|
* [Your software] for TREK
|
||||||
|
|
||||||
|
## Questions and permission requests
|
||||||
|
|
||||||
|
If you are not sure whether your intended use of the Marks is permitted under
|
||||||
|
this policy, or if you would like to request explicit permission for a use that
|
||||||
|
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||||
|
the maintainers directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These guidelines are based on the
|
||||||
|
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||||
|
under a
|
||||||
|
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||||
|
|
||||||
|
echo "==> Installing client dependencies"
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Building client"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Installing server dependencies"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Populating server/public"
|
||||||
|
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||||
|
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||||
|
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||||
|
|
||||||
|
echo "==> Done — server/public is ready"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 2.9.14
|
version: 3.0.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||||
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Playwright E2E (FE7)
|
||||||
|
e2e/.tmp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Relative to the config dir (client/), matching `storageState` in
|
||||||
|
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||||
|
const stateFile = 'e2e/.tmp/state.json'
|
||||||
|
|
||||||
|
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
|
||||||
|
// seeded admin is created with must_change_password=1, so the first login goes
|
||||||
|
// through the forced change-password step before reaching the dashboard.
|
||||||
|
const EMAIL = 'e2e@trek.local'
|
||||||
|
const SEED_PW = 'E2eTest12345!'
|
||||||
|
const NEW_PW = 'E2eChanged12345!'
|
||||||
|
|
||||||
|
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.locator('input[type="email"]').fill(EMAIL)
|
||||||
|
await page.locator('input[type="password"]').fill(SEED_PW)
|
||||||
|
await page.locator('button[type="submit"]').click()
|
||||||
|
|
||||||
|
// must_change_password=1 → the change-password step renders two password
|
||||||
|
// fields (new + confirm). Selector-agnostic of the UI language.
|
||||||
|
const pw = page.locator('input[type="password"]')
|
||||||
|
await expect(pw).toHaveCount(2)
|
||||||
|
await pw.nth(0).fill(NEW_PW)
|
||||||
|
await pw.nth(1).fill(NEW_PW)
|
||||||
|
await page.locator('button[type="submit"]').click()
|
||||||
|
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||||
|
|
||||||
|
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||||
|
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||||
|
// Dismissal is recorded server-side against this user, so clearing it here
|
||||||
|
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||||
|
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||||
|
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||||
|
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||||
|
await ok.click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.context().storageState({ path: stateFile })
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||||
|
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||||
|
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
|
||||||
|
// re-render — against the real backend + isolated test DB.
|
||||||
|
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
|
||||||
|
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||||
|
await page.locator('.add-trip-card').click()
|
||||||
|
|
||||||
|
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||||
|
// button (the primary action lives in the footer), so click it explicitly
|
||||||
|
// rather than pressing Enter. The Create button is the slate primary button;
|
||||||
|
// Cancel is the bordered one.
|
||||||
|
const modal = page.locator('.modal-backdrop')
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
|
||||||
|
const title = `E2E Trip ${Date.now()}`
|
||||||
|
await modal.locator('input[type="text"]').first().fill(title)
|
||||||
|
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Authenticated smoke: the stored session lands on the dashboard and the
|
||||||
|
// app chrome (navbar) renders instead of bouncing back to /login.
|
||||||
|
test('authenticated session reaches the dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/)
|
||||||
|
// The shared Navbar shows the TREK brand once authenticated.
|
||||||
|
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Infra smoke + first unauthenticated flow: the app boots, the backend is
|
||||||
|
// reachable through the Vite proxy, and the login screen renders its form.
|
||||||
|
test('login screen renders with a password field', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await expect(page.locator('input[type="password"]')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
|
||||||
|
// SQLite database. The DB file is deleted first so every run starts clean, then
|
||||||
|
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
|
||||||
|
//
|
||||||
|
// The server is built once and launched as a SINGLE node process (not the
|
||||||
|
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
|
||||||
|
// that survive Playwright's teardown and then linger on :3001 with stale DB
|
||||||
|
// state). A single child is killed cleanly when Playwright tears the run down.
|
||||||
|
import { rmSync } from 'node:fs'
|
||||||
|
import { spawn, execSync } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const dbFile = path.join(here, '.tmp', 'e2e.db')
|
||||||
|
const serverDir = path.join(here, '..', '..', 'server')
|
||||||
|
|
||||||
|
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||||
|
try { rmSync(f, { force: true }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build once (no watcher) — the resulting process is a single killable node.
|
||||||
|
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
TREK_DB_FILE: dbFile,
|
||||||
|
ADMIN_EMAIL: 'e2e@trek.local',
|
||||||
|
ADMIN_PASSWORD: 'E2eTest12345!',
|
||||||
|
PORT: '3001',
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
|
||||||
|
cwd: serverDir,
|
||||||
|
env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
const stop = () => { try { child.kill() } catch {} }
|
||||||
|
process.on('SIGINT', stop)
|
||||||
|
process.on('SIGTERM', stop)
|
||||||
|
process.on('exit', stop)
|
||||||
|
child.on('exit', code => process.exit(code ?? 0))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||||
|
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||||
|
// mounts, proving the day-plan/map shell renders rather than crashing on load.
|
||||||
|
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
|
||||||
|
// Create a trip to open.
|
||||||
|
await page.locator('.add-trip-card').click()
|
||||||
|
const modal = page.locator('.modal-backdrop')
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
const title = `E2E Planner ${Date.now()}`
|
||||||
|
await modal.locator('input[type="text"]').first().fill(title)
|
||||||
|
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||||
|
|
||||||
|
// Open it from the dashboard.
|
||||||
|
await page.getByText(title).first().click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||||
|
// The planner shows a Leaflet map once mounted (past the splash screen).
|
||||||
|
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
|
||||||
|
import gitignore from 'eslint-config-flat-gitignore';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
|
||||||
|
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
|
||||||
|
// eslint-plugin-react (not a dependency here). The rule is a no-op.
|
||||||
|
const reactStub = {
|
||||||
|
rules: {
|
||||||
|
'no-danger': {
|
||||||
|
meta: { schema: [] },
|
||||||
|
create() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
gitignore({ strict: false }),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'coverage',
|
||||||
|
'public',
|
||||||
|
'test-results',
|
||||||
|
'playwright-report',
|
||||||
|
'e2e/**',
|
||||||
|
'scripts/**',
|
||||||
|
'**/*.config.js',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'**/*.config.mjs',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
react: reactStub,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/no-danger': 'off',
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
|
||||||
|
// --- Severities tuned to keep CI green on a codebase that was never linted ---
|
||||||
|
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
|
||||||
|
|
||||||
|
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
|
||||||
|
'react-hooks/rules-of-hooks': 'warn',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||||
|
'@typescript-eslint/no-this-alias': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
|
||||||
|
|
||||||
|
// js.recommended rules with pre-existing hits.
|
||||||
|
'no-empty': 'warn',
|
||||||
|
'no-useless-escape': 'warn',
|
||||||
|
'no-useless-assignment': 'warn',
|
||||||
|
'preserve-caught-error': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- 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"
|
||||||
|
|||||||
+33
-10
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "@trek/client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,25 +8,35 @@
|
|||||||
"prebuild": "node scripts/generate-icons.mjs",
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:report": "playwright show-report",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^4.1.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
@@ -34,26 +44,39 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"msw": "^2.13.0",
|
"msw": "^2.13.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"sharp": "^0.33.0",
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E harness for TREK's critical user flows (FE7).
|
||||||
|
*
|
||||||
|
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
|
||||||
|
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
|
||||||
|
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
|
||||||
|
* /ws to the backend. Tests run serially against one worker so they share the
|
||||||
|
* single seeded database deterministically.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
timeout: 45_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
// Unauthenticated flows (login, register, public share) — no stored session.
|
||||||
|
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
|
||||||
|
// One-time login that persists a session for the authenticated flows.
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||||
|
{
|
||||||
|
name: 'app',
|
||||||
|
testMatch: /\.spec\.ts/,
|
||||||
|
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
|
||||||
|
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
// Always start our own backend (never reuse) so the isolated test DB is
|
||||||
|
// reset + reseeded on every run, regardless of any stray dev server.
|
||||||
|
command: 'node e2e/server-launch.mjs',
|
||||||
|
port: 3001,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Guards the "Page = wiring container + data hook" convention (see
|
||||||
|
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||||
|
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||||
|
//
|
||||||
|
// We scan only the default-export component body (from `export default function`
|
||||||
|
// up to the next top-level `function` declaration or EOF), so presentational
|
||||||
|
// sub-components and helper hooks living in the same file are not flagged.
|
||||||
|
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||||
|
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||||
|
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||||
|
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||||
|
|
||||||
|
const violations = []
|
||||||
|
for (const file of readdirSync(pagesDir)) {
|
||||||
|
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||||
|
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||||
|
const lines = src.split('\n')
|
||||||
|
const start = lines.findIndex(l => /export default function/.test(l))
|
||||||
|
if (start === -1) continue
|
||||||
|
// The page body ends at the next top-level declaration (a `function` at
|
||||||
|
// column 0) — everything after that is a sub-component or helper.
|
||||||
|
let end = lines.length
|
||||||
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
|
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||||
|
}
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (bannedRe.test(lines[i])) {
|
||||||
|
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
|
||||||
|
for (const v of violations) console.error(' ' + v)
|
||||||
|
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('Page pattern OK — no state/effect logic in page containers.')
|
||||||
+3
-3
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
setDemoMode(!!config?.demo_mode)
|
||||||
if (config?.dev_mode) setDevMode(true)
|
if (config?.dev_mode) setDevMode(true)
|
||||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||||
if (config?.version) setAppVersion(config.version)
|
if (config?.version) setAppVersion(config.version)
|
||||||
@@ -218,7 +218,7 @@ export default function App() {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+307
-158
@@ -1,30 +1,109 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import {
|
||||||
|
weatherResultSchema, type WeatherResult,
|
||||||
|
inAppListResultSchema, type InAppListResult,
|
||||||
|
unreadCountResultSchema, type UnreadCountResult,
|
||||||
|
channelTestResultSchema,
|
||||||
|
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||||
|
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||||
|
type NotificationRespondRequest,
|
||||||
|
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||||
|
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||||
|
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||||
|
type JourneyShareLinkRequest,
|
||||||
|
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||||
|
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||||
|
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||||
|
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||||
|
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||||
|
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||||
|
type DayCreateRequest, type DayUpdateRequest,
|
||||||
|
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||||
|
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||||
|
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||||
|
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||||
|
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||||
|
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||||
|
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||||
|
type PlaceBulkDeleteRequest,
|
||||||
|
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||||
|
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||||
|
type PackingCategoryAssigneesRequest,
|
||||||
|
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||||
|
type TodoCategoryAssigneesRequest,
|
||||||
|
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||||
|
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||||
|
type FileUpdateRequest, type FileLinkRequest,
|
||||||
|
type CreateTagRequest, type UpdateTagRequest,
|
||||||
|
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||||
|
type PlaceImportListRequest,
|
||||||
|
} from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import en from '../i18n/translations/en'
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import br from '../i18n/translations/br'
|
|
||||||
import de from '../i18n/translations/de'
|
|
||||||
import es from '../i18n/translations/es'
|
|
||||||
import fr from '../i18n/translations/fr'
|
|
||||||
import it from '../i18n/translations/it'
|
|
||||||
import nl from '../i18n/translations/nl'
|
|
||||||
import pl from '../i18n/translations/pl'
|
|
||||||
import cs from '../i18n/translations/cs'
|
|
||||||
import hu from '../i18n/translations/hu'
|
|
||||||
import ru from '../i18n/translations/ru'
|
|
||||||
import zh from '../i18n/translations/zh'
|
|
||||||
import zhTw from '../i18n/translations/zhTw'
|
|
||||||
import ar from '../i18n/translations/ar'
|
|
||||||
|
|
||||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
/**
|
||||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||||
|
* dev, and never throwing. A drift between the server contract and the client's
|
||||||
|
* expected shape is surfaced as a console warning during development; in
|
||||||
|
* production (and on any mismatch) the data passes through untouched, so adding
|
||||||
|
* validation can never break a working call. This is the typed-request helper
|
||||||
|
* the FE adopts per domain as each backend module lands on @trek/shared.
|
||||||
|
*/
|
||||||
|
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
||||||
|
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
||||||
|
if (API_DEV) {
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data as z.infer<S>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same dev-only drift check as parseInDev, but passes the payload straight
|
||||||
|
* through with its original inferred type instead of the schema type. Use this
|
||||||
|
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
||||||
|
* adds the development contract-drift warning without retyping the public
|
||||||
|
* surface (so it can never break a consumer that worked before).
|
||||||
|
*/
|
||||||
|
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
||||||
|
if (API_DEV) {
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||||
|
en: 'Too many attempts. Please try again later.',
|
||||||
|
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||||
|
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||||
|
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||||
|
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||||
|
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||||
|
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||||
|
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||||
|
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||||
|
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||||
|
zh: '尝试次数过多,请稍后再试。',
|
||||||
|
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||||
|
it: 'Troppi tentativi. Riprova più tardi.',
|
||||||
|
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||||
|
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||||
|
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||||
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||||
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateRateLimit(): string {
|
function translateRateLimit(): string {
|
||||||
const fallback = 'Too many attempts. Please try again later.'
|
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||||
try {
|
try {
|
||||||
const lang = localStorage.getItem('app_language') || 'en'
|
const lang = localStorage.getItem('app_language') || 'en'
|
||||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -33,6 +112,7 @@ function translateRateLimit(): string {
|
|||||||
export const apiClient: AxiosInstance = axios.create({
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
timeout: 8000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -42,24 +122,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|||||||
|
|
||||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
// Attach a per-request idempotency key to all write operations so the
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
// server can deduplicate retried requests (e.g. network blips).
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
const method = (config.method ?? '').toLowerCase()
|
const method = (config.method ?? '').toLowerCase()
|
||||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: Math.random().toString(36).slice(2)
|
: Math.random().toString(36).slice(2)
|
||||||
config.headers['X-Idempotency-Key'] = key
|
config.headers['X-Idempotency-Key'] = key
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
@@ -68,45 +148,93 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||||
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||||
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||||
|
async function unregisterSWAndReload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration()
|
||||||
|
if (reg) await reg.unregister()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
return response
|
||||||
const { pathname } = window.location
|
},
|
||||||
if (!isAuthPublicPath(pathname)) {
|
async (error) => {
|
||||||
const currentPath = pathname + window.location.search
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
// as a CORS error with no response object. Probe the health endpoint to
|
||||||
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||||
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||||
|
if (!error.response && navigator.onLine) {
|
||||||
|
await probeNow()
|
||||||
|
// Both the original request and the health probe failed while the device
|
||||||
|
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||||
|
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||||
|
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||||
|
// down would also land here, but only reloads once).
|
||||||
|
if (!isReachable()) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
if (
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
error.response?.status === 403 &&
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
if (error.response?.status === 401) {
|
||||||
!window.location.pathname.startsWith('/settings')
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
) {
|
if (ct.includes('text/html')) {
|
||||||
window.location.href = '/settings?mfa=required'
|
const { pathname } = window.location
|
||||||
}
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
if (error.response?.status === 429) {
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
const translated = translateRateLimit()
|
await unregisterSWAndReload()
|
||||||
const data = error.response.data as { error?: string } | undefined
|
return Promise.reject(error)
|
||||||
if (data && typeof data === 'object') {
|
}
|
||||||
data.error = translated
|
}
|
||||||
} else {
|
|
||||||
error.response.data = { error: translated }
|
|
||||||
}
|
}
|
||||||
error.message = translated
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname)) {
|
||||||
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
const translated = translateRateLimit()
|
||||||
|
const data = error.response.data as { error?: string } | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
data.error = translated
|
||||||
|
} else {
|
||||||
|
error.response.data = { error: translated }
|
||||||
|
}
|
||||||
|
error.message = translated
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
login: (data: LoginRequest) => 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),
|
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).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 as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
mfaEnable: (data: MfaEnableRequest) => 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),
|
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),
|
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),
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||||
@@ -120,14 +248,14 @@ export const authApi = {
|
|||||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||||
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
mcpTokens: {
|
mcpTokens: {
|
||||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -142,6 +270,7 @@ export const oauthApi = {
|
|||||||
state?: string
|
state?: string
|
||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
|
resource?: string
|
||||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||||
|
|
||||||
/** Submit user consent (approve or deny) */
|
/** Submit user consent (approve or deny) */
|
||||||
@@ -153,12 +282,13 @@ export const oauthApi = {
|
|||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
approved: boolean
|
approved: boolean
|
||||||
|
resource?: string
|
||||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
@@ -171,32 +301,32 @@ export const oauthApi = {
|
|||||||
|
|
||||||
export const tripsApi = {
|
export const tripsApi = {
|
||||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
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),
|
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
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),
|
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const daysApi = {
|
export const daysApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placesApi = {
|
export const placesApi = {
|
||||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||||
@@ -215,64 +345,64 @@ export const placesApi = {
|
|||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string) =>
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
||||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const todoApi = {
|
export const todoApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
|
||||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
list: () => apiClient.get('/tags').then(r => r.data),
|
list: () => apiClient.get('/tags').then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
||||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const categoriesApi = {
|
export const categoriesApi = {
|
||||||
list: () => apiClient.get('/categories').then(r => r.data),
|
list: () => apiClient.get('/categories').then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
||||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +443,7 @@ export const adminApi = {
|
|||||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').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),
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||||
@@ -322,7 +452,7 @@ export const adminApi = {
|
|||||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { 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),
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
sendTestNotification: (data: Record<string, unknown>) =>
|
sendTestNotification: (data: Record<string, unknown>) =>
|
||||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||||
@@ -335,7 +465,7 @@ export const addonsApi = {
|
|||||||
|
|
||||||
export const journeyApi = {
|
export const journeyApi = {
|
||||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||||
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||||
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||||
@@ -344,7 +474,7 @@ export const journeyApi = {
|
|||||||
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||||
|
|
||||||
// Trips (sync sources)
|
// Trips (sync sources)
|
||||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
|
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
|
||||||
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||||
|
|
||||||
// Entries
|
// Entries
|
||||||
@@ -352,12 +482,24 @@ export const journeyApi = {
|
|||||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||||
@@ -379,19 +521,19 @@ export const journeyApi = {
|
|||||||
|
|
||||||
// Share
|
// Share
|
||||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||||
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||||
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const airportsApi = {
|
export const airportsApi = {
|
||||||
@@ -401,15 +543,15 @@ export const airportsApi = {
|
|||||||
|
|
||||||
export const budgetApi = {
|
export const budgetApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
@@ -417,71 +559,78 @@ export const filesApi = {
|
|||||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||||
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reservationsApi = {
|
export const reservationsApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
|
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||||
apiClient.get('/config').then(r => r.data),
|
apiClient.get('/config').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => apiClient.get('/settings').then(r => r.data),
|
get: () => apiClient.get('/settings').then(r => r.data),
|
||||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
set: (key: string, value: unknown) => {
|
||||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
const body: SettingUpsertRequest = { key, value }
|
||||||
|
return apiClient.put('/settings', body).then(r => r.data)
|
||||||
|
},
|
||||||
|
setBulk: (settings: Record<string, unknown>) => {
|
||||||
|
const body: SettingsBulkRequest = { settings }
|
||||||
|
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const accommodationsApi = {
|
export const accommodationsApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dayNotesApi = {
|
export const dayNotesApi = {
|
||||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const collabApi = {
|
export const collabApi = {
|
||||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,28 +671,28 @@ export const shareApi = {
|
|||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
updatePreferences: (prefs: Record<string, 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),
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
|
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
||||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
||||||
unreadCount: () =>
|
unreadCount: (): Promise<UnreadCountResult> =>
|
||||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
||||||
markRead: (id: number) =>
|
markRead: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||||
markUnread: (id: number) =>
|
markUnread: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||||
markAllRead: () =>
|
markAllRead: () =>
|
||||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||||
delete: (id: number) =>
|
delete: (id: number) =>
|
||||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
deleteAll: () =>
|
deleteAll: () =>
|
||||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||||
respond: (id: number, response: 'positive' | 'negative') =>
|
respond: (id: number, response: NotificationRespondRequest['response']) =>
|
||||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
@@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addons.length === 0 ? (
|
{addons.length === 0 ? (
|
||||||
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
<div className="p-8 text-center text-sm text-content-faint">
|
||||||
{t('admin.addons.noAddons')}
|
{t('admin.addons.noAddons')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
{/* Trip Addons */}
|
{/* Trip Addons */}
|
||||||
{tripAddons.length > 0 && (
|
{tripAddons.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
<Briefcase size={13} className="text-content-muted" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,14 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
<div key={addon.id}>
|
<div key={addon.id}>
|
||||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onToggleBagTracking}
|
<button onClick={onToggleBagTracking}
|
||||||
@@ -205,20 +205,20 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{COLLAB_SUB_FEATURES.map(feat => {
|
{COLLAB_SUB_FEATURES.map(feat => {
|
||||||
const enabled = collabFeatures[feat.key]
|
const enabled = collabFeatures[feat.key]
|
||||||
const Icon = feat.icon
|
const Icon = feat.icon
|
||||||
return (
|
return (
|
||||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||||
@@ -242,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
{/* Global Addons */}
|
{/* Global Addons */}
|
||||||
{globalAddons.length > 0 && (
|
{globalAddons.length > 0 && (
|
||||||
<div>
|
<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)' }}>
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
<Globe size={13} className="text-content-muted" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,19 +253,19 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
{/* Memories providers as sub-items under Journey addon */}
|
{/* Memories providers as sub-items under Journey addon */}
|
||||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{providerOptions.map(provider => {
|
{providerOptions.map(provider => {
|
||||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||||
return (
|
return (
|
||||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -291,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
{/* Integration Addons */}
|
{/* Integration Addons */}
|
||||||
{integrationAddons.length > 0 && (
|
{integrationAddons.length > 0 && (
|
||||||
<div>
|
<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)' }}>
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
<Link2 size={13} className="text-content-muted" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,31 +336,31 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
|||||||
const displayDescription = descriptionOverride || label.description
|
const displayDescription = descriptionOverride || label.description
|
||||||
const enabledState = statusOverride ?? addon.enabled
|
const enabledState = statusOverride ?? addon.enabled
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||||
<AddonIcon name={addon.icon} size={20} />
|
<AddonIcon name={addon.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||||
{isComingSoon && (
|
{isComingSoon && (
|
||||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||||
Coming Soon
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
{!hideToggle && (
|
{!hideToggle && (
|
||||||
@@ -371,9 +371,8 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
|||||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-card)',
|
|
||||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -83,14 +83,14 @@ export default function AdminMcpTokensPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OAuth Sessions */}
|
{/* OAuth Sessions */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||||
{sessionsLoading ? (
|
{sessionsLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
@@ -102,8 +102,8 @@ export default function AdminMcpTokensPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
|
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||||
<span>{t('admin.oauthSessions.owner')}</span>
|
<span>{t('admin.oauthSessions.owner')}</span>
|
||||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||||
@@ -115,34 +115,31 @@ export default function AdminMcpTokensPanel() {
|
|||||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||||
return (
|
return (
|
||||||
<div key={session.id}
|
<div key={session.id}
|
||||||
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||||
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
{visible.map(scope => (
|
{visible.map(scope => (
|
||||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{scope}
|
{scope}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{!expanded && hidden > 0 && (
|
{!expanded && hidden > 0 && (
|
||||||
<button onClick={() => toggleScopes(session.id)}
|
<button onClick={() => toggleScopes(session.id)}
|
||||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
|
||||||
+{hidden} more
|
+{hidden} more
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{expanded && hidden > 0 && (
|
{expanded && hidden > 0 && (
|
||||||
<button onClick={() => toggleScopes(session.id)}
|
<button onClick={() => toggleScopes(session.id)}
|
||||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
|
||||||
show less
|
show less
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="whitespace-nowrap">{session.username}</span>
|
<span className="whitespace-nowrap">{session.username}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,8 +161,8 @@ export default function AdminMcpTokensPanel() {
|
|||||||
|
|
||||||
{/* MCP Tokens */}
|
{/* MCP Tokens */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||||
{tokensLoading ? (
|
{tokensLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
@@ -177,8 +174,8 @@ export default function AdminMcpTokensPanel() {
|
|||||||
</div>
|
</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"
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||||
<span>{t('admin.mcpTokens.owner')}</span>
|
<span>{t('admin.mcpTokens.owner')}</span>
|
||||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||||
@@ -187,13 +184,12 @@ export default function AdminMcpTokensPanel() {
|
|||||||
</div>
|
</div>
|
||||||
{tokens.map((token, i) => (
|
{tokens.map((token, i) => (
|
||||||
<div key={token.id}
|
<div key={token.id}
|
||||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="whitespace-nowrap">{token.username}</span>
|
<span className="whitespace-nowrap">{token.username}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,14 +213,14 @@ export default function AdminMcpTokensPanel() {
|
|||||||
|
|
||||||
{/* Revoke OAuth session modal */}
|
{/* Revoke OAuth session modal */}
|
||||||
{revokeConfirmId !== null && (
|
{revokeConfirmId !== null && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button onClick={() => setRevokeConfirmId(null)}
|
<button onClick={() => setRevokeConfirmId(null)}
|
||||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||||
@@ -238,14 +234,14 @@ export default function AdminMcpTokensPanel() {
|
|||||||
|
|
||||||
{/* Delete MCP token modal */}
|
{/* Delete MCP token modal */}
|
||||||
{deleteConfirmId !== null && (
|
{deleteConfirmId !== null && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
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)' }}>
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button onClick={() => setDeleteConfirmId(null)}
|
<button onClick={() => setDeleteConfirmId(null)}
|
||||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||||
|
|||||||
@@ -100,54 +100,53 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||||
<ClipboardList size={20} />
|
<ClipboardList size={20} />
|
||||||
{t('admin.tabs.audit')}
|
{t('admin.tabs.audit')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => loadFirstPage()}
|
onClick={() => loadFirstPage()}
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
{t('admin.audit.refresh')}
|
{t('admin.audit.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
<p className="text-xs m-0 text-content-faint">
|
||||||
{t('admin.audit.showing', { count: entries.length, total })}
|
{t('admin.audit.showing', { count: entries.length, total })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && entries.length === 0 ? (
|
{loading && entries.length === 0 ? (
|
||||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
<tr className="border-b text-left border-edge-secondary">
|
||||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -160,8 +159,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => loadMore()}
|
onClick={() => loadMore()}
|
||||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
>
|
||||||
{t('admin.audit.loadMore')}
|
{t('admin.audit.loadMore')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -310,8 +310,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Clock className="w-5 h-5 text-gray-400" />
|
<Clock className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={String(autoSettings.hour)}
|
value={String(autoSettings.hour)}
|
||||||
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||||
size="sm"
|
size="sm"
|
||||||
options={HOURS.map(h => {
|
options={HOURS.map(h => {
|
||||||
let label: string
|
let label: string
|
||||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={String(autoSettings.day_of_month)}
|
value={String(autoSettings.day_of_month)}
|
||||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||||
size="sm"
|
size="sm"
|
||||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||||
/>
|
/>
|
||||||
@@ -458,7 +458,8 @@ export default function BackupPanel() {
|
|||||||
{/* Restore Warning Modal */}
|
{/* Restore Warning Modal */}
|
||||||
{restoreConfirm && (
|
{restoreConfirm && (
|
||||||
<div
|
<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 }}
|
className="bg-[rgba(0,0,0,0.5)]"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
onClick={() => setRestoreConfirm(null)}
|
onClick={() => setRestoreConfirm(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -468,14 +469,14 @@ export default function BackupPanel() {
|
|||||||
>
|
>
|
||||||
{/* Red header */}
|
{/* Red header */}
|
||||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<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 }}>
|
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
<AlertTriangle size={20} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||||
{t('backup.restoreConfirmTitle')}
|
{t('backup.restoreConfirmTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||||
{restoreConfirm.filename}
|
{restoreConfirm.filename}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,7 +506,8 @@ export default function BackupPanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={executeRestore}
|
onClick={executeRestore}
|
||||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
className="bg-[#dc2626] text-white"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ export default function CategoryManager() {
|
|||||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleStartCreate}
|
<button onClick={handleStartCreate}
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type Defaults = {
|
|||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
route_calculation?: boolean
|
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
}
|
}
|
||||||
@@ -36,10 +35,10 @@ function OptionRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -114,8 +113,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
isSet(field) ? (
|
isSet(field) ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => reset(field)}
|
onClick={() => reset(field)}
|
||||||
className="text-xs ml-2"
|
className="text-xs ml-2 text-content-faint underline"
|
||||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||||
</button>
|
</button>
|
||||||
@@ -131,7 +130,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
lng: 2.3522,
|
lng: 2.3522,
|
||||||
address: null,
|
address: null,
|
||||||
category_id: null,
|
category_id: null,
|
||||||
icon: null,
|
|
||||||
price: null,
|
price: null,
|
||||||
currency: null,
|
currency: null,
|
||||||
image_url: null,
|
image_url: null,
|
||||||
@@ -148,14 +146,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
}], [])
|
}], [])
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
const darkMode = defaults.dark_mode
|
const darkMode = defaults.dark_mode
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||||
{t('admin.defaultSettings.description')}
|
{t('admin.defaultSettings.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -208,22 +206,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
{/* Route Calculation */}
|
|
||||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
|
||||||
{([
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton
|
|
||||||
key={String(opt.value)}
|
|
||||||
active={defaults.route_calculation === opt.value}
|
|
||||||
onClick={() => save({ route_calculation: opt.value })}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
@@ -242,7 +224,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Map Tile URL */}
|
{/* Map Tile URL */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||||
{t('settings.mapTemplate')}
|
{t('settings.mapTemplate')}
|
||||||
<ResetButton field="map_tile_url" />
|
<ResetButton field="map_tile_url" />
|
||||||
</label>
|
</label>
|
||||||
@@ -262,7 +244,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
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"
|
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 mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
{React.createElement(MapView as any, {
|
{React.createElement(MapView as any, {
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={sending !== null}
|
disabled={sending !== null}
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||||
>
|
>
|
||||||
@@ -78,8 +77,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
<p className="text-sm font-medium text-content">{label}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||||
</div>
|
</div>
|
||||||
{sending === id && (
|
{sending === id && (
|
||||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||||
@@ -88,15 +87,14 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||||
)
|
)
|
||||||
|
|
||||||
const TripSelector = () => (
|
const TripSelector = () => (
|
||||||
<select
|
<select
|
||||||
value={selectedTripId ?? ''}
|
value={selectedTripId ?? ''}
|
||||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||||
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>)}
|
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -106,8 +104,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
<select
|
<select
|
||||||
value={selectedUserId ?? ''}
|
value={selectedUserId ?? ''}
|
||||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||||
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>)}
|
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -116,10 +113,10 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||||
DEV ONLY
|
DEV ONLY
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-sm font-medium text-content">
|
||||||
Notification Testing
|
Notification Testing
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +124,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>Type Testing</SectionTitle>
|
<SectionTitle>Type Testing</SectionTitle>
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs mb-3 text-content-muted">
|
||||||
Test how each in-app notification type renders, sent to yourself.
|
Test how each in-app notification type renders, sent to yourself.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
@@ -175,7 +172,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
{trips.length > 0 && (
|
{trips.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs mb-3 text-content-muted">
|
||||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||||
</p>
|
</p>
|
||||||
<TripSelector />
|
<TripSelector />
|
||||||
@@ -228,7 +225,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
{users.length > 0 && (
|
{users.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs mb-3 text-content-muted">
|
||||||
Fires each user event to the selected recipient.
|
Fires each user event to the selected recipient.
|
||||||
</p>
|
</p>
|
||||||
<UserSelector />
|
<UserSelector />
|
||||||
@@ -266,7 +263,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
|||||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs mb-3 text-content-muted">
|
||||||
Fires to all admin users.
|
Fires to all admin users.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ const PER_PAGE = 10
|
|||||||
interface GithubRelease {
|
interface GithubRelease {
|
||||||
id: number
|
id: number
|
||||||
prerelease: boolean
|
prerelease: boolean
|
||||||
|
tag_name: string
|
||||||
|
name: string | null
|
||||||
|
body: string | null
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
author: { login: string } | null
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
elements.push(
|
elements.push(
|
||||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||||
{listItems.map((item, i) => (
|
{listItems.map((item, i) => (
|
||||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||||
</li>
|
</li>
|
||||||
@@ -96,14 +102,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
if (trimmed.startsWith('### ')) {
|
if (trimmed.startsWith('### ')) {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||||
{trimmed.slice(4)}
|
{trimmed.slice(4)}
|
||||||
</h4>
|
</h4>
|
||||||
)
|
)
|
||||||
} else if (trimmed.startsWith('## ')) {
|
} else if (trimmed.startsWith('## ')) {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||||
{trimmed.slice(3)}
|
{trimmed.slice(3)}
|
||||||
</h3>
|
</h3>
|
||||||
)
|
)
|
||||||
@@ -112,7 +118,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
} else {
|
} else {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -130,55 +136,52 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://ko-fi.com/mauriceboe"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://buymeacoffee.com/mauriceboe"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
<Heart size={20} className="text-[#ffdd00]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/NhZBDSd4qW"
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
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' }}
|
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' }}
|
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 }}>
|
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, 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>
|
<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>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
<div className="text-sm font-semibold text-content">Discord</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
<div className="text-xs text-content-faint">Join the community</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,85 +190,81 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
<Bug size={20} className="text-[#ef4444]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/mauriceboe/TREK/wiki"
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
<BookOpen size={20} className="text-[#6366f1]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading / Error / Releases */}
|
{/* Loading / Error / Releases */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
<div className="p-8 flex items-center justify-center">
|
<div className="p-8 flex items-center justify-center">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
<div className="p-6 text-center">
|
<div className="p-6 text-center">
|
||||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${REPO}/releases`}
|
href={`https://github.com/${REPO}/releases`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
GitHub
|
GitHub
|
||||||
@@ -299,36 +298,34 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
{/* Release content */}
|
{/* Release content */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-sm font-semibold text-content">
|
||||||
{release.tag_name}
|
{release.tag_name}
|
||||||
</span>
|
</span>
|
||||||
{isLatest && (
|
{isLatest && (
|
||||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
|
||||||
{t('admin.github.latest')}
|
{t('admin.github.latest')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{release.prerelease && (
|
{release.prerelease && (
|
||||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
|
||||||
{t('admin.github.prerelease')}
|
{t('admin.github.prerelease')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{release.name && release.name !== release.tag_name && (
|
{release.name && release.name !== release.tag_name && (
|
||||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||||
{release.name}
|
{release.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||||
<Calendar size={10} />
|
<Calendar size={10} />
|
||||||
{formatDate(release.published_at || release.created_at)}
|
{formatDate(release.published_at || release.created_at)}
|
||||||
</span>
|
</span>
|
||||||
{release.author && (
|
{release.author && (
|
||||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
<span className="text-[11px] text-content-faint">
|
||||||
{t('admin.github.by')} {release.author.login}
|
{t('admin.github.by')} {release.author.login}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -339,15 +336,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpand(release.id)}
|
onClick={() => toggleExpand(release.id)}
|
||||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||||
{renderBody(release.body)}
|
{renderBody(release.body)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -366,8 +362,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
<button
|
<button
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
disabled={loadingMore}
|
disabled={loadingMore}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||||
|
|||||||
@@ -500,7 +500,8 @@ describe('PackingTemplateManager', () => {
|
|||||||
|
|
||||||
// Find the X (cancel) button in the create row — it's the last button in the create row
|
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||||
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||||
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
|
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
|
||||||
|
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
|
||||||
await user.click(cancelBtn);
|
await user.click(cancelBtn);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const CURRENCIES = [
|
||||||
|
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||||
|
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||||
|
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||||
|
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||||
|
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SYMBOLS: Record<string, string> = {
|
||||||
|
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||||
|
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||||
|
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||||
|
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||||
|
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||||
|
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||||
|
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||||
|
PEN: 'S/.', ARS: 'AR$',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||||
|
|
||||||
|
export const SPLIT_COLORS = [
|
||||||
|
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||||
|
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||||
|
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||||
|
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||||
|
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||||
|
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||||
|
|
||||||
|
export function widgetTheme(dark: boolean) {
|
||||||
|
if (dark) return {
|
||||||
|
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||||
|
border: 'rgba(255,255,255,0.07)',
|
||||||
|
text: '#ffffff',
|
||||||
|
sub: 'rgba(255,255,255,0.6)',
|
||||||
|
faint: 'rgba(255,255,255,0.4)',
|
||||||
|
track: 'rgba(255,255,255,0.04)',
|
||||||
|
divider: 'rgba(255,255,255,0.07)',
|
||||||
|
iconBg: 'rgba(255,255,255,0.08)',
|
||||||
|
iconBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
iconColor: 'rgba(255,255,255,0.9)',
|
||||||
|
centerBg: '#17171d',
|
||||||
|
flowBg: 'rgba(255,255,255,0.05)',
|
||||||
|
flowBorder: 'rgba(255,255,255,0.07)',
|
||||||
|
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||||
|
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
rowHover: 'rgba(255,255,255,0.03)',
|
||||||
|
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||||
|
border: 'rgba(15,23,42,0.08)',
|
||||||
|
text: '#111827',
|
||||||
|
sub: 'rgba(17,24,39,0.6)',
|
||||||
|
faint: 'rgba(17,24,39,0.4)',
|
||||||
|
track: 'rgba(15,23,42,0.05)',
|
||||||
|
divider: 'rgba(15,23,42,0.08)',
|
||||||
|
iconBg: 'rgba(15,23,42,0.05)',
|
||||||
|
iconBorder: 'rgba(15,23,42,0.1)',
|
||||||
|
iconColor: 'rgba(17,24,39,0.75)',
|
||||||
|
centerBg: '#ffffff',
|
||||||
|
flowBg: 'rgba(15,23,42,0.03)',
|
||||||
|
flowBorder: 'rgba(15,23,42,0.08)',
|
||||||
|
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||||
|
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||||
|
rowHover: 'rgba(15,23,42,0.04)',
|
||||||
|
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexLighten(hex: string, amount: number): string {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g)
|
||||||
|
if (!m || m.length !== 3) return hex
|
||||||
|
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||||
|
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||||
|
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||||
|
if (v == null || isNaN(v)) return '-'
|
||||||
|
const d = currencyDecimals(cur)
|
||||||
|
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumOrNull = number | null | undefined
|
||||||
|
|
||||||
|
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
|
||||||
|
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
|
||||||
|
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
|
||||||
|
|
||||||
|
export function splitColorFor(userId: number, order: number) {
|
||||||
|
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorForUserId(userId: number) {
|
||||||
|
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
@@ -66,7 +66,8 @@ describe('BudgetPanel', () => {
|
|||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
);
|
);
|
||||||
render(<BudgetPanel tripId={1} />);
|
render(<BudgetPanel tripId={1} />);
|
||||||
await screen.findByText('Transport');
|
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||||
|
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||||
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
|
|||||||
);
|
);
|
||||||
render(<BudgetPanel tripId={1} />);
|
render(<BudgetPanel tripId={1} />);
|
||||||
await screen.findByText('Name');
|
await screen.findByText('Name');
|
||||||
await screen.findByText('Total');
|
// 'Total' appears both as a table header and in the chart total label.
|
||||||
|
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||||
@@ -169,8 +171,9 @@ describe('BudgetPanel', () => {
|
|||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||||
);
|
);
|
||||||
render(<BudgetPanel tripId={1} />);
|
render(<BudgetPanel tripId={1} />);
|
||||||
await screen.findByText('Transport');
|
// Each category appears in its section header and again in the breakdown chart.
|
||||||
await screen.findByText('Hotels');
|
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||||
|
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||||
@@ -200,7 +203,8 @@ describe('BudgetPanel', () => {
|
|||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
);
|
);
|
||||||
render(<BudgetPanel tripId={1} />);
|
render(<BudgetPanel tripId={1} />);
|
||||||
await screen.findByText('ToDelete');
|
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||||
|
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
|
|||||||
const item = {
|
const item = {
|
||||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||||
total_price: 75,
|
total_price: 75,
|
||||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
|
|||||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
// Use a user with id != 1 so they're not the owner
|
// Use a user with id != 1 so they're not the owner
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
@@ -439,7 +443,7 @@ describe('BudgetPanel', () => {
|
|||||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
@@ -484,7 +488,7 @@ describe('BudgetPanel', () => {
|
|||||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
|
||||||
|
interface AddItemRowProps {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [persons, setPersons] = useState('')
|
||||||
|
const [days, setDays] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [expenseDate, setExpenseDate] = useState('')
|
||||||
|
const nameRef = useRef<HTMLInputElement>(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, expense_date: expenseDate || null })
|
||||||
|
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||||
|
setTimeout(() => nameRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="bg-surface-secondary">
|
||||||
|
<td style={{ padding: '4px 6px' }}>
|
||||||
|
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
placeholder={t('budget.newEntry')} style={inp} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '4px 6px' }}>
|
||||||
|
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||||
|
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||||
|
</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: 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: 60, margin: '0 auto' }} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', 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>
|
||||||
|
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||||
|
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||||
|
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||||
|
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||||
|
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||||
|
import AddItemRow from './BudgetPanelAddItemRow'
|
||||||
|
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||||
|
import type { EditingCat, AddItemData } from './useBudgetPanel'
|
||||||
|
|
||||||
|
interface BudgetCategoryTableProps {
|
||||||
|
cat: string
|
||||||
|
grouped: Map<string, BudgetItem[]>
|
||||||
|
categoryColor: (cat: string) => string
|
||||||
|
canEdit: boolean
|
||||||
|
editingCat: EditingCat | null
|
||||||
|
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
|
||||||
|
dragCat: string | null
|
||||||
|
setDragCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
dragOverCat: string | null
|
||||||
|
setDragOverCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
dragItem: number | null
|
||||||
|
setDragItem: Dispatch<SetStateAction<number | null>>
|
||||||
|
dragOverItem: number | null
|
||||||
|
setDragOverItem: Dispatch<SetStateAction<number | null>>
|
||||||
|
dragItemCat: string | null
|
||||||
|
setDragItemCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
categoryNames: string[]
|
||||||
|
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||||
|
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||||
|
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||||
|
handleDeleteCategory: (cat: string) => Promise<void>
|
||||||
|
handleDeleteItem: (id: number) => Promise<void>
|
||||||
|
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
|
||||||
|
handleAddItem: (category: string, data: AddItemData) => Promise<void>
|
||||||
|
tripId: number
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
t: (key: string) => string
|
||||||
|
fmt: (v: number | null | undefined, cur: string) => string
|
||||||
|
hasMultipleMembers: boolean
|
||||||
|
tripMembers: TripMember[]
|
||||||
|
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
|
||||||
|
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||||
|
th: CSSProperties
|
||||||
|
td: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
|
||||||
|
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
|
||||||
|
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
|
||||||
|
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||||
|
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||||
|
const items = grouped.get(cat) || []
|
||||||
|
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||||
|
const color = categoryColor(cat)
|
||||||
|
return (
|
||||||
|
<div key={cat} data-drag-cat={cat} style={{
|
||||||
|
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onDragOver={e => {
|
||||||
|
if (!dragCat || dragCat === cat || dragItem) return
|
||||||
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverCat(cat)
|
||||||
|
}}
|
||||||
|
onDragLeave={e => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
|
||||||
|
}}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragCat && dragCat !== cat) {
|
||||||
|
const newOrder = [...categoryNames]
|
||||||
|
const fromIdx = newOrder.indexOf(dragCat)
|
||||||
|
const toIdx = newOrder.indexOf(cat)
|
||||||
|
newOrder.splice(fromIdx, 1)
|
||||||
|
newOrder.splice(toIdx, 0, dragCat)
|
||||||
|
reorderBudgetCategories(tripId, newOrder)
|
||||||
|
}
|
||||||
|
setDragCat(null); setDragOverCat(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
|
||||||
|
<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 }}>
|
||||||
|
{canEdit && (
|
||||||
|
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
|
||||||
|
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
|
||||||
|
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||||
|
{canEdit && editingCat?.name === cat ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editingCat.value}
|
||||||
|
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||||
|
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||||
|
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||||
|
{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>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
|
||||||
|
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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, 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>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => {
|
||||||
|
const pp = calcPP(item.total_price, item.persons)
|
||||||
|
const pd = calcPD(item.total_price, item.days)
|
||||||
|
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||||
|
const hasMembers = (item.members?.length ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<tr key={item.id}
|
||||||
|
style={{
|
||||||
|
transition: 'background 0.1s, opacity 0.15s',
|
||||||
|
opacity: dragItem === item.id ? 0.4 : 1,
|
||||||
|
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
|
||||||
|
}}
|
||||||
|
onDragOver={e => {
|
||||||
|
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
|
||||||
|
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
|
||||||
|
}}
|
||||||
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
|
||||||
|
onDrop={e => {
|
||||||
|
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const ids = items.map(i => i.id)
|
||||||
|
const fromIdx = ids.indexOf(dragItem)
|
||||||
|
const toIdx = ids.indexOf(item.id)
|
||||||
|
ids.splice(fromIdx, 1)
|
||||||
|
ids.splice(toIdx, 0, dragItem)
|
||||||
|
reorderBudgetItems(tripId, ids)
|
||||||
|
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<td style={td}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{canEdit && (
|
||||||
|
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||||
|
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||||
|
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||||
|
<GripVertical size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||||
|
{hasMultipleMembers && (
|
||||||
|
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||||
|
<BudgetMemberChips
|
||||||
|
members={item.members || []}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
compact={false}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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')} readOnly={!canEdit} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||||
|
{hasMultipleMembers ? (
|
||||||
|
<BudgetMemberChips
|
||||||
|
members={item.members || []}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
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 as string) || 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 as string) || 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, 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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface InlineEditCellProps {
|
||||||
|
value: string | number | null | undefined
|
||||||
|
onSave: (value: string | number | null) => void
|
||||||
|
type?: 'text' | 'number'
|
||||||
|
style?: React.CSSProperties
|
||||||
|
placeholder?: string
|
||||||
|
decimals?: number
|
||||||
|
locale: string
|
||||||
|
editTooltip?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState<string | number>(value ?? '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setEditing(false)
|
||||||
|
let v: string | number | null = editValue
|
||||||
|
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||||
|
if (v !== value) onSave(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (type !== 'number') return
|
||||||
|
e.preventDefault()
|
||||||
|
let text = e.clipboardData.getData('text').trim()
|
||||||
|
// Strip everything except digits, dots, commas, minus
|
||||||
|
text = text.replace(/[^\d.,-]/g, '')
|
||||||
|
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||||
|
const lastComma = text.lastIndexOf(',')
|
||||||
|
const lastDot = text.lastIndexOf('.')
|
||||||
|
const decimalPos = Math.max(lastComma, lastDot)
|
||||||
|
if (decimalPos > -1) {
|
||||||
|
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||||
|
const decPart = text.substring(decimalPos + 1)
|
||||||
|
text = intPart + '.' + decPart
|
||||||
|
} else {
|
||||||
|
text = text.replace(/[.,]/g, '')
|
||||||
|
}
|
||||||
|
setEditValue(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||||
|
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||||
|
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||||
|
placeholder={placeholder} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = type === 'number' && value != null
|
||||||
|
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
: (value || '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
{display || placeholder || '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Pencil, Users, Check } from 'lucide-react'
|
||||||
|
import type { BudgetItemMember } from '../../types'
|
||||||
|
|
||||||
|
export interface TripMember {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||||
|
interface ChipWithTooltipProps {
|
||||||
|
label: string
|
||||||
|
avatarUrl: string | null
|
||||||
|
size?: number
|
||||||
|
paid?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setHover(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||||
|
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||||
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||||
|
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: label?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{hover && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
{paid && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||||
|
}}>Paid</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||||
|
interface BudgetMemberChipsProps {
|
||||||
|
members?: BudgetItemMember[]
|
||||||
|
tripMembers?: TripMember[]
|
||||||
|
onSetMembers: (memberIds: number[]) => void
|
||||||
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
|
compact?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default 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)
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const openDropdown = useCallback(() => {
|
||||||
|
if (btnRef.current) {
|
||||||
|
const rect = btnRef.current.getBoundingClientRect()
|
||||||
|
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setShowDropdown(v => !v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDropdown) return
|
||||||
|
const close = (e: MouseEvent) => {
|
||||||
|
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
|
||||||
|
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [showDropdown])
|
||||||
|
|
||||||
|
const memberIds = members.map(m => m.user_id)
|
||||||
|
|
||||||
|
const toggleMember = (userId: number) => {
|
||||||
|
const newIds = memberIds.includes(userId)
|
||||||
|
? memberIds.filter(id => id !== userId)
|
||||||
|
: [...memberIds, userId]
|
||||||
|
onSetMembers(newIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
{members.map(m => (
|
||||||
|
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||||
|
paid={!!m.paid}
|
||||||
|
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!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,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||||
|
}}>
|
||||||
|
{tripMembers.map(tm => {
|
||||||
|
const isActive = memberIds.includes(tm.id)
|
||||||
|
return (
|
||||||
|
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||||
|
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||||
|
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{tm.avatar_url
|
||||||
|
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: tm.username?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||||
|
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
|
||||||
|
import RingAvatar from './BudgetPanelRingAvatar'
|
||||||
|
|
||||||
|
interface PerPersonSummaryEntry {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
total_assigned: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerPersonInlineProps {
|
||||||
|
tripId: number
|
||||||
|
budgetItems: BudgetItem[]
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||||
|
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
|
||||||
|
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||||
|
}, [tripId, budgetItems])
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
|
||||||
|
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{grandTotal > 0 && (
|
||||||
|
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||||
|
{people.map(p => (
|
||||||
|
<div key={p.user_id} style={{
|
||||||
|
height: '100%', borderRadius: 999,
|
||||||
|
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||||
|
background: p.color.gradient,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||||
|
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Wallet } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PieSegment {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||||
|
interface PieChartProps {
|
||||||
|
segments: PieSegment[]
|
||||||
|
size?: number
|
||||||
|
totalLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||||
|
if (!segments.length) return null
|
||||||
|
|
||||||
|
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
let cumDeg = 0
|
||||||
|
const stops = segments.map(seg => {
|
||||||
|
const start = cumDeg
|
||||||
|
const deg = (seg.value / total) * 360
|
||||||
|
cumDeg += deg
|
||||||
|
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||||
|
}).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
className="trek-pie-reveal"
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: `conic-gradient(${stops})`,
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: size * 0.55, height: size * 0.55,
|
||||||
|
borderRadius: '50%', background: 'var(--bg-card)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||||
|
}}>
|
||||||
|
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { colorForUserId } from './BudgetPanel.helpers'
|
||||||
|
|
||||||
|
export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||||
|
const color = colorForUserId(userId)
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||||
|
padding: 2, background: color.gradient,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%', borderRadius: '50%',
|
||||||
|
background: innerBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||||
|
}}>
|
||||||
|
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { SYMBOLS } from './BudgetPanel.constants'
|
||||||
|
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
|
||||||
|
import RingAvatar from './BudgetPanelRingAvatar'
|
||||||
|
import PerPersonInline from './BudgetPanelPerPersonInline'
|
||||||
|
import type { SettlementData, PieSegment } from './useBudgetPanel'
|
||||||
|
|
||||||
|
interface BudgetSummaryProps {
|
||||||
|
theme: ReturnType<typeof widgetTheme>
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
grandTotal: number
|
||||||
|
hasMultipleMembers: boolean
|
||||||
|
budgetItems: BudgetItem[]
|
||||||
|
settlement: SettlementData | null
|
||||||
|
settlementOpen: boolean
|
||||||
|
setSettlementOpen: Dispatch<SetStateAction<boolean>>
|
||||||
|
pieSegments: PieSegment[]
|
||||||
|
isDark: boolean
|
||||||
|
tripId: number
|
||||||
|
t: (key: string) => string
|
||||||
|
fmt: (v: number | null | undefined, cur: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
|
||||||
|
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 12,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Wallet size={20} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const decimals = currencyDecimals(currency)
|
||||||
|
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
|
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||||
|
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||||
|
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>{currency}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
|
||||||
|
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settlement dropdown inside the total card */}
|
||||||
|
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
|
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||||
|
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||||
|
}}>
|
||||||
|
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
{t('budget.settlement')}
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||||
|
<span style={{ display: 'flex', cursor: 'help' }}
|
||||||
|
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||||
|
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info size={11} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||||
|
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{t('budget.settlementInfo')}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{settlementOpen && (
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{settlement.flows.map((flow, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '12px 14px', borderRadius: 14,
|
||||||
|
background: theme.flowBg,
|
||||||
|
border: `1px solid ${theme.flowBorder}`,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||||
|
>
|
||||||
|
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||||
|
{fmt(flow.amount, currency)}
|
||||||
|
</span>
|
||||||
|
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||||
|
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||||
|
{t('budget.netBalances')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||||
|
const positive = b.balance > 0
|
||||||
|
const Trend = positive ? TrendingUp : TrendingDown
|
||||||
|
return (
|
||||||
|
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||||
|
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{b.username}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '4px 10px', borderRadius: 8,
|
||||||
|
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||||
|
color: positive ? '#10b981' : '#ef4444',
|
||||||
|
}}>
|
||||||
|
<Trend size={11} strokeWidth={3} />
|
||||||
|
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pieSegments.length > 0 && (() => {
|
||||||
|
const decimals = currencyDecimals(currency)
|
||||||
|
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||||
|
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
|
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||||
|
const R = 80
|
||||||
|
const CIRC = 2 * Math.PI * R
|
||||||
|
let dashOffset = 0
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 11,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<PieChartIcon size={18} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||||
|
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||||
|
<defs>
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
|
return (
|
||||||
|
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={seg.color} />
|
||||||
|
<stop offset="100%" stopColor={c2} />
|
||||||
|
</linearGradient>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</defs>
|
||||||
|
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||||
|
const circle = (
|
||||||
|
<circle key={i}
|
||||||
|
cx={100} cy={100} r={R}
|
||||||
|
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||||
|
stroke={`url(#cat-grad-${i})`}
|
||||||
|
strokeDasharray={`${segLen} ${CIRC}`}
|
||||||
|
strokeDashoffset={-dashOffset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
dashOffset += segLen
|
||||||
|
return circle
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||||
|
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||||
|
<span>{totalInt}</span>
|
||||||
|
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||||
|
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||||
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
|
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||||
|
return (
|
||||||
|
<div key={seg.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '10px 8px', borderRadius: 12,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||||
|
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||||
|
boxShadow: `0 0 12px ${seg.color}80`,
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '4px 9px', borderRadius: 7,
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
background: `${seg.color}26`,
|
||||||
|
border: `1px solid ${seg.color}40`,
|
||||||
|
color: chipColor,
|
||||||
|
}}>{pctLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||||
|
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||||
|
import type { TripMember } from './BudgetPanelMemberChips'
|
||||||
|
|
||||||
|
function useIsDark(): boolean {
|
||||||
|
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
return () => mo.disconnect()
|
||||||
|
}, [])
|
||||||
|
return dark
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditingCat {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementPerson {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementFlow {
|
||||||
|
from: SettlementPerson
|
||||||
|
to: SettlementPerson
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementBalance {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettlementData {
|
||||||
|
balances: SettlementBalance[]
|
||||||
|
flows: SettlementFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PieSegment {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddItemData {
|
||||||
|
name: string
|
||||||
|
total_price: number
|
||||||
|
persons: number | null
|
||||||
|
days: number | null
|
||||||
|
note: string | null
|
||||||
|
expense_date: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||||
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||||
|
const can = useCanDo()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const isDark = useIsDark()
|
||||||
|
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
|
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
|
||||||
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
|
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||||
|
const currency = trip?.currency || 'EUR'
|
||||||
|
const canEdit = can('budget_edit', trip)
|
||||||
|
|
||||||
|
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
|
||||||
|
const hasMultipleMembers = tripMembers.length > 1
|
||||||
|
|
||||||
|
// Drag state for categories
|
||||||
|
const [dragCat, setDragCat] = useState<string | null>(null)
|
||||||
|
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
|
||||||
|
// Drag state for items within a category
|
||||||
|
const [dragItem, setDragItem] = useState<number | null>(null)
|
||||||
|
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
|
||||||
|
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load settlement data whenever budget items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMultipleMembers) return
|
||||||
|
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||||
|
}, [tripId, budgetItems, hasMultipleMembers])
|
||||||
|
|
||||||
|
const setCurrency = (cur: string) => {
|
||||||
|
if (tripId) updateTrip(tripId, { currency: cur })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, BudgetItem[]>()
|
||||||
|
for (const item of (budgetItems || [])) {
|
||||||
|
const cat = item.category || 'Other'
|
||||||
|
if (!map.has(cat)) map.set(cat, [])
|
||||||
|
map.get(cat)!.push(item)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [budgetItems])
|
||||||
|
|
||||||
|
const categoryNames = Array.from(grouped.keys())
|
||||||
|
|
||||||
|
// Stable color mapping: assign index-based colors once, never reassign on reorder
|
||||||
|
const colorMapRef = useRef(new Map<string, string>())
|
||||||
|
const categoryColor = useCallback((cat: string) => {
|
||||||
|
const map = colorMapRef.current
|
||||||
|
if (!map.has(cat)) {
|
||||||
|
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
|
||||||
|
}
|
||||||
|
return map.get(cat)!
|
||||||
|
}, [])
|
||||||
|
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||||
|
|
||||||
|
const pieSegments = useMemo<PieSegment[]>(() =>
|
||||||
|
categoryNames.map((cat, i) => ({
|
||||||
|
name: cat,
|
||||||
|
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
|
||||||
|
color: categoryColor(cat),
|
||||||
|
})).filter(s => s.value > 0)
|
||||||
|
, [grouped, categoryNames])
|
||||||
|
|
||||||
|
const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleDeleteCategory = async (cat: string) => {
|
||||||
|
const items = grouped.get(cat) || []
|
||||||
|
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
|
||||||
|
catch { toast.error(t('common.error')) }
|
||||||
|
}
|
||||||
|
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||||
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
|
const items = grouped.get(oldName) || []
|
||||||
|
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
|
||||||
|
catch { toast.error(t('common.error')) }
|
||||||
|
}
|
||||||
|
const handleAddCategory = () => {
|
||||||
|
if (!newCategoryName.trim()) return
|
||||||
|
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
|
||||||
|
.catch(() => toast.error(t('common.error')))
|
||||||
|
setNewCategoryName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const sep = ';'
|
||||||
|
const esc = (v: unknown) => { 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.get(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 = ''
|
||||||
|
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À-ɏ _-]/g, '').trim()
|
||||||
|
a.download = `budget-${safeName}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: CSSProperties = { 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: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
|
return {
|
||||||
|
trip, budgetItems,
|
||||||
|
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||||
|
t, locale, isDark, theme,
|
||||||
|
newCategoryName, setNewCategoryName,
|
||||||
|
editingCat, setEditingCat,
|
||||||
|
settlement, settlementOpen, setSettlementOpen,
|
||||||
|
currency, canEdit, fmt, hasMultipleMembers,
|
||||||
|
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||||
|
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||||
|
setCurrency,
|
||||||
|
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||||
|
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||||
|
th, td,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const EMOJI_CATEGORIES = {
|
||||||
|
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||||
|
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||||
|
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction Quick Menu (right-click)
|
||||||
|
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||||
|
|
||||||
|
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||||
|
export function emojiToCodepoint(emoji) {
|
||||||
|
const codepoints = []
|
||||||
|
for (const c of emoji) {
|
||||||
|
const cp = c.codePointAt(0)
|
||||||
|
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||||
|
}
|
||||||
|
return codepoints.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||||
|
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||||
|
|
||||||
|
export function formatTime(isoString, is12h) {
|
||||||
|
const d = parseUTC(isoString)
|
||||||
|
const h = d.getHours()
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
return `${h12}:${mm} ${period}`
|
||||||
|
}
|
||||||
|
return `${String(h).padStart(2, '0')}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateSeparator(isoString, t) {
|
||||||
|
const d = parseUTC(isoString)
|
||||||
|
const now = new Date()
|
||||||
|
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||||
|
|
||||||
|
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||||
|
|
||||||
|
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowDateSeparator(msg, prevMsg) {
|
||||||
|
if (!prevMsg) return true
|
||||||
|
const d1 = parseUTC(msg.created_at).toDateString()
|
||||||
|
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||||
|
return d1 !== d2
|
||||||
|
}
|
||||||
@@ -1,350 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
import { ArrowUp, Reply, 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'
|
import type { User } from '../../types'
|
||||||
|
import { useCollabChat } from './useCollabChat'
|
||||||
interface ChatReaction {
|
import { ChatMessages } from './CollabChatMessages'
|
||||||
emoji: string
|
import { EmojiPicker } from './CollabChatEmojiPicker'
|
||||||
count: number
|
import { ReactionMenu } from './CollabChatReactionMenu'
|
||||||
users: { id: number; username: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
id: number
|
|
||||||
trip_id: number
|
|
||||||
user_id: number
|
|
||||||
text: string
|
|
||||||
reply_to_id: number | null
|
|
||||||
reactions: ChatReaction[]
|
|
||||||
created_at: string
|
|
||||||
user?: { username: string; avatar_url: string | null }
|
|
||||||
reply_to?: ChatMessage | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
|
||||||
function emojiToCodepoint(emoji) {
|
|
||||||
const codepoints = []
|
|
||||||
for (const c of emoji) {
|
|
||||||
const cp = c.codePointAt(0)
|
|
||||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
|
||||||
}
|
|
||||||
return codepoints.join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
|
||||||
const cp = emojiToCodepoint(emoji)
|
|
||||||
const [failed, setFailed] = useState(false)
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
|
||||||
alt={emoji}
|
|
||||||
draggable={false}
|
|
||||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMOJI_CATEGORIES = {
|
|
||||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
|
||||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
|
||||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
|
||||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
|
||||||
|
|
||||||
function formatTime(isoString, is12h) {
|
|
||||||
const d = parseUTC(isoString)
|
|
||||||
const h = d.getHours()
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
||||||
if (is12h) {
|
|
||||||
const period = h >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
||||||
return `${h12}:${mm} ${period}`
|
|
||||||
}
|
|
||||||
return `${String(h).padStart(2, '0')}:${mm}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateSeparator(isoString, t) {
|
|
||||||
const d = parseUTC(isoString)
|
|
||||||
const now = new Date()
|
|
||||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
|
||||||
|
|
||||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
|
||||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
|
||||||
|
|
||||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowDateSeparator(msg, prevMsg) {
|
|
||||||
if (!prevMsg) return true
|
|
||||||
const d1 = parseUTC(msg.created_at).toDateString()
|
|
||||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
|
||||||
return d1 !== d2
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Emoji Picker ── */
|
|
||||||
interface EmojiPickerProps {
|
|
||||||
onSelect: (emoji: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
anchorRef: React.RefObject<HTMLElement | null>
|
|
||||||
containerRef: React.RefObject<HTMLElement | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
|
||||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const getPos = () => {
|
|
||||||
const container = containerRef?.current
|
|
||||||
const anchor = anchorRef?.current
|
|
||||||
if (container && anchor) {
|
|
||||||
const cRect = container.getBoundingClientRect()
|
|
||||||
const aRect = anchor.getBoundingClientRect()
|
|
||||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
|
||||||
}
|
|
||||||
return { bottom: 80, left: 0 }
|
|
||||||
}
|
|
||||||
const pos = getPos()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const close = (e) => {
|
|
||||||
if (ref.current && ref.current.contains(e.target)) return
|
|
||||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [onClose, anchorRef])
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<div ref={ref} style={{
|
|
||||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Category tabs */}
|
|
||||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
|
||||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
|
||||||
<button key={c} onClick={() => setCat(c)} style={{
|
|
||||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
|
||||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
|
||||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
{c}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Emoji grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
|
||||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
|
||||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
|
||||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
|
||||||
padding: 2, transition: 'transform 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={emoji} size={20} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reaction Quick Menu (right-click) ── */
|
|
||||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
|
||||||
|
|
||||||
interface ReactionMenuProps {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
onReact: (emoji: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
// Clamp to viewport
|
|
||||||
const menuWidth = 156
|
|
||||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} style={{
|
|
||||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
|
||||||
}}>
|
|
||||||
{QUICK_REACTIONS.map(emoji => (
|
|
||||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
|
||||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
|
||||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={emoji} size={18} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Message Text with clickable URLs ── */
|
|
||||||
interface MessageTextProps {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageText({ text }: MessageTextProps) {
|
|
||||||
const parts = text.split(URL_REGEX)
|
|
||||||
const urls = text.match(URL_REGEX) || []
|
|
||||||
const result = []
|
|
||||||
parts.forEach((part, i) => {
|
|
||||||
if (part) result.push(part)
|
|
||||||
if (urls[i]) result.push(
|
|
||||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
|
||||||
{urls[i]}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return <>{result}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Link Preview ── */
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
|
||||||
const previewCache = {}
|
|
||||||
|
|
||||||
interface LinkPreviewProps {
|
|
||||||
url: string
|
|
||||||
tripId: number
|
|
||||||
own: boolean
|
|
||||||
onLoad: (() => void) | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
|
||||||
const [data, setData] = useState(previewCache[url] || null)
|
|
||||||
const [loading, setLoading] = useState(!previewCache[url])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previewCache[url]) return
|
|
||||||
collabApi.linkPreview(tripId, url).then(d => {
|
|
||||||
previewCache[url] = d
|
|
||||||
setData(d)
|
|
||||||
setLoading(false)
|
|
||||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
|
||||||
}).catch(() => setLoading(false))
|
|
||||||
}, [url, tripId])
|
|
||||||
|
|
||||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
|
||||||
|
|
||||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
|
||||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
|
||||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
|
||||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
|
||||||
maxWidth: 280, transition: 'opacity 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
>
|
|
||||||
{data.image && (
|
|
||||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
|
||||||
onError={e => e.target.style.display = 'none'} />
|
|
||||||
)}
|
|
||||||
<div style={{ padding: '8px 10px' }}>
|
|
||||||
{domain && (
|
|
||||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
|
||||||
{data.site_name || domain}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.title && (
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{data.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.description && (
|
|
||||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{data.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
|
||||||
interface ReactionBadgeProps {
|
|
||||||
reaction: ChatReaction
|
|
||||||
currentUserId: number
|
|
||||||
onReact: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
||||||
const ref = useRef(null)
|
|
||||||
const names = reaction.users.map(u => u.username).join(', ')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button ref={ref} onClick={onReact}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (ref.current) {
|
|
||||||
const rect = ref.current.getBoundingClientRect()
|
|
||||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
|
||||||
}
|
|
||||||
setHover(true)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setHover(false)}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
|
||||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
background: 'transparent', transition: 'transform 0.1s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
|
||||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
|
||||||
</button>
|
|
||||||
{hover && names && ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
|
||||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
|
||||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
|
||||||
}}>
|
|
||||||
{names}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main Component ── */
|
/* ── Main Component ── */
|
||||||
interface CollabChatProps {
|
interface CollabChatProps {
|
||||||
@@ -353,173 +13,8 @@ interface CollabChatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||||
const { t } = useTranslation()
|
const S = useCollabChat(tripId, currentUser)
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||||
const can = useCanDo()
|
|
||||||
const trip = useTripStore((s) => s.trip)
|
|
||||||
const canEdit = can('collab_edit', trip)
|
|
||||||
|
|
||||||
const [messages, setMessages] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [hasMore, setHasMore] = useState(false)
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [replyTo, setReplyTo] = useState(null)
|
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
|
||||||
const [sending, setSending] = useState(false)
|
|
||||||
const [showEmoji, setShowEmoji] = useState(false)
|
|
||||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
|
||||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
|
||||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const containerRef = useRef(null)
|
|
||||||
const messagesRef = useRef(messages)
|
|
||||||
messagesRef.current = messages
|
|
||||||
const scrollRef = useRef(null)
|
|
||||||
const textareaRef = useRef(null)
|
|
||||||
const emojiBtnRef = useRef(null)
|
|
||||||
const isAtBottom = useRef(true)
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const checkAtBottom = useCallback(() => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── load messages ── */
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
collabApi.getMessages(tripId).then(data => {
|
|
||||||
if (cancelled) return
|
|
||||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
|
||||||
setMessages(msgs)
|
|
||||||
setHasMore(msgs.length >= 100)
|
|
||||||
setLoading(false)
|
|
||||||
setTimeout(() => scrollToBottom(), 30)
|
|
||||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [tripId, scrollToBottom])
|
|
||||||
|
|
||||||
/* ── load more ── */
|
|
||||||
const handleLoadMore = useCallback(async () => {
|
|
||||||
if (loadingMore || messages.length === 0) return
|
|
||||||
setLoadingMore(true)
|
|
||||||
const el = scrollRef.current
|
|
||||||
const prevHeight = el ? el.scrollHeight : 0
|
|
||||||
try {
|
|
||||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
|
||||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
|
||||||
if (older.length === 0) { setHasMore(false) }
|
|
||||||
else {
|
|
||||||
setMessages(prev => [...older, ...prev])
|
|
||||||
setHasMore(older.length >= 100)
|
|
||||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
|
||||||
}
|
|
||||||
} catch {} finally { setLoadingMore(false) }
|
|
||||||
}, [tripId, loadingMore, messages])
|
|
||||||
|
|
||||||
/* ── websocket ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (event) => {
|
|
||||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
|
||||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
|
||||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addListener(handler)
|
|
||||||
return () => removeListener(handler)
|
|
||||||
}, [tripId, scrollToBottom])
|
|
||||||
|
|
||||||
/* ── auto-resize textarea ── */
|
|
||||||
const handleTextChange = useCallback((e) => {
|
|
||||||
setText(e.target.value)
|
|
||||||
const ta = textareaRef.current
|
|
||||||
if (ta) {
|
|
||||||
ta.style.height = 'auto'
|
|
||||||
const h = Math.min(ta.scrollHeight, 100)
|
|
||||||
ta.style.height = h + 'px'
|
|
||||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── send ── */
|
|
||||||
const handleSend = useCallback(async () => {
|
|
||||||
const body = text.trim()
|
|
||||||
if (!body || sending) return
|
|
||||||
setSending(true)
|
|
||||||
try {
|
|
||||||
const payload = { text: body }
|
|
||||||
if (replyTo) payload.reply_to = replyTo.id
|
|
||||||
const data = await collabApi.sendMessage(tripId, payload)
|
|
||||||
if (data?.message) {
|
|
||||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
|
||||||
}
|
|
||||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
||||||
isAtBottom.current = true
|
|
||||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
|
||||||
} catch {} finally { setSending(false) }
|
|
||||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
|
||||||
}, [handleSend])
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async (msgId) => {
|
|
||||||
const msg = messages.find(m => m.id === msgId)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
|
||||||
})
|
|
||||||
const t = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await collabApi.deleteMessage(tripId, msgId)
|
|
||||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
|
||||||
} catch {}
|
|
||||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
|
||||||
}, 400)
|
|
||||||
deleteTimersRef.current.push(t)
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const handleReact = useCallback(async (msgId, emoji) => {
|
|
||||||
setReactMenu(null)
|
|
||||||
try {
|
|
||||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
|
||||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
|
||||||
} catch {}
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback((emoji) => {
|
|
||||||
setText(prev => prev + emoji)
|
|
||||||
textareaRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
|
||||||
|
|
||||||
// Check if message is only emoji (1-3 emojis, no other text)
|
|
||||||
const isEmojiOnly = (text) => {
|
|
||||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
|
|
||||||
return emojiRegex.test(text.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading ── */
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -528,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||||
{/* Messages */}
|
<ChatMessages {...S} />
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
|
||||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
|
||||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
|
||||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 1,
|
|
||||||
}}>
|
|
||||||
{hasMore && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
|
||||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
|
||||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
|
||||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<ChevronUp size={13} />
|
|
||||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((msg, idx) => {
|
|
||||||
const own = isOwn(msg)
|
|
||||||
const prevMsg = messages[idx - 1]
|
|
||||||
const nextMsg = messages[idx + 1]
|
|
||||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
|
||||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
|
||||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
|
||||||
const showAvatar = !own && isLastInGroup
|
|
||||||
const bigEmoji = isEmojiOnly(msg.text)
|
|
||||||
const hasReply = msg.reply_text || msg.reply_to
|
|
||||||
// Deleted message placeholder
|
|
||||||
if (msg._deleted) {
|
|
||||||
return (
|
|
||||||
<React.Fragment key={msg.id}>
|
|
||||||
{showDate && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
|
||||||
{formatDateSeparator(msg.created_at, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
|
||||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bubble border radius — iMessage style tails
|
|
||||||
const br = own
|
|
||||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
|
||||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={msg.id}>
|
|
||||||
{/* Date separator */}
|
|
||||||
{showDate && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
|
||||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
|
||||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
{formatDateSeparator(msg.created_at, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
|
||||||
flexDirection: own ? 'row-reverse' : 'row',
|
|
||||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
|
||||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
|
||||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
|
||||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
|
||||||
}}>
|
|
||||||
{/* Avatar slot for others */}
|
|
||||||
{!own && (
|
|
||||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
|
||||||
{showAvatar && (
|
|
||||||
msg.user_avatar ? (
|
|
||||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{(msg.username || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
|
||||||
{/* Username for others at group start */}
|
|
||||||
{!own && isNewGroup && (
|
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
|
||||||
{msg.username}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
onMouseEnter={() => setHoveredId(msg.id)}
|
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
|
||||||
onContextMenu={e => { e.preventDefault(); 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 && canEdit) {
|
|
||||||
e.preventDefault()
|
|
||||||
const touch = e.changedTouches?.[0]
|
|
||||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
|
||||||
}
|
|
||||||
e.currentTarget.dataset.lastTap = now
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bigEmoji ? (
|
|
||||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
|
||||||
color: own ? '#fff' : 'var(--text-primary)',
|
|
||||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
|
||||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
|
||||||
}}>
|
|
||||||
{/* Inline reply quote */}
|
|
||||||
{hasReply && (
|
|
||||||
<div style={{
|
|
||||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
|
||||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
|
||||||
fontSize: 12, lineHeight: 1.3,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
|
||||||
{msg.reply_username || ''}
|
|
||||||
</div>
|
|
||||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{(msg.reply_text || '').slice(0, 80)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasReply ? (
|
|
||||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
|
||||||
) : <MessageText text={msg.text} />}
|
|
||||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
|
||||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hover actions */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: -14,
|
|
||||||
display: 'flex', gap: 2,
|
|
||||||
opacity: hoveredId === msg.id ? 1 : 0,
|
|
||||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
|
||||||
transition: 'opacity .1s',
|
|
||||||
...(own ? { left: -6 } : { right: -6 }),
|
|
||||||
}}>
|
|
||||||
<button onClick={() => setReplyTo(msg)} title={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,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
|
||||||
>
|
|
||||||
<Reply size={11} />
|
|
||||||
</button>
|
|
||||||
{own && 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,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
|
||||||
>
|
|
||||||
<Trash2 size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reactions — iMessage style floating badge */}
|
|
||||||
{msg.reactions?.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
|
||||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
|
||||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
|
||||||
position: 'relative', zIndex: 1,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
|
||||||
borderRadius: 99, background: 'var(--bg-card)',
|
|
||||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
|
||||||
}}>
|
|
||||||
{msg.reactions.map(r => {
|
|
||||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
|
||||||
return (
|
|
||||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — only on last message of group */}
|
|
||||||
{isLastInGroup && (
|
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
|
||||||
{formatTime(msg.created_at, is12h)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Composer */}
|
{/* Composer */}
|
||||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
|
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface ChatReaction {
|
||||||
|
emoji: string
|
||||||
|
count: number
|
||||||
|
users: { id: number; username: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number
|
||||||
|
trip_id: number
|
||||||
|
user_id: number
|
||||||
|
text: string
|
||||||
|
reply_to_id: number | null
|
||||||
|
reactions: ChatReaction[]
|
||||||
|
created_at: string
|
||||||
|
user?: { username: string; avatar_url: string | null }
|
||||||
|
reply_to?: ChatMessage | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { EMOJI_CATEGORIES } from './CollabChat.constants'
|
||||||
|
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||||
|
|
||||||
|
/* ── Emoji Picker ── */
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
onSelect: (emoji: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||||
|
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const getPos = () => {
|
||||||
|
const container = containerRef?.current
|
||||||
|
const anchor = anchorRef?.current
|
||||||
|
if (container && anchor) {
|
||||||
|
const cRect = container.getBoundingClientRect()
|
||||||
|
const aRect = anchor.getBoundingClientRect()
|
||||||
|
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||||
|
}
|
||||||
|
return { bottom: 80, left: 0 }
|
||||||
|
}
|
||||||
|
const pos = getPos()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e) => {
|
||||||
|
if (ref.current && ref.current.contains(e.target)) return
|
||||||
|
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [onClose, anchorRef])
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div ref={ref} style={{
|
||||||
|
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||||
|
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||||
|
<button key={c} onClick={() => setCat(c)} style={{
|
||||||
|
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Emoji grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||||
|
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||||
|
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||||
|
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||||
|
padding: 2, transition: 'transform 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||||
|
>
|
||||||
|
<TwemojiImg emoji={emoji} size={20} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { collabApi } from '../../api/client'
|
||||||
|
|
||||||
|
/* ── Link Preview ── */
|
||||||
|
const previewCache = {}
|
||||||
|
|
||||||
|
interface LinkPreviewProps {
|
||||||
|
url: string
|
||||||
|
tripId: number
|
||||||
|
own: boolean
|
||||||
|
onLoad: (() => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||||
|
const [data, setData] = useState(previewCache[url] || null)
|
||||||
|
const [loading, setLoading] = useState(!previewCache[url])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewCache[url]) return
|
||||||
|
collabApi.linkPreview(tripId, url).then(d => {
|
||||||
|
previewCache[url] = d
|
||||||
|
setData(d)
|
||||||
|
setLoading(false)
|
||||||
|
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [url, tripId])
|
||||||
|
|
||||||
|
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||||
|
|
||||||
|
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||||
|
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||||
|
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||||
|
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||||
|
maxWidth: 280, transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
{data.image && (
|
||||||
|
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||||
|
onError={e => e.currentTarget.style.display = 'none'} />
|
||||||
|
)}
|
||||||
|
<div style={{ padding: '8px 10px' }}>
|
||||||
|
{domain && (
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||||
|
{data.site_name || domain}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.title && (
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{data.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.description && (
|
||||||
|
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{data.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { URL_REGEX } from './CollabChat.constants'
|
||||||
|
|
||||||
|
/* ── Message Text with clickable URLs ── */
|
||||||
|
interface MessageTextProps {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageText({ text }: MessageTextProps) {
|
||||||
|
const parts = text.split(URL_REGEX)
|
||||||
|
const urls = text.match(URL_REGEX) || []
|
||||||
|
const result = []
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
if (part) result.push(part)
|
||||||
|
if (urls[i]) result.push(
|
||||||
|
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||||
|
{urls[i]}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return <>{result}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||||
|
import { URL_REGEX } from './CollabChat.constants'
|
||||||
|
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||||
|
import { MessageText } from './CollabChatMessageText'
|
||||||
|
import { LinkPreview } from './CollabChatLinkPreview'
|
||||||
|
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||||
|
|
||||||
|
export function ChatMessages(props: any) {
|
||||||
|
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Messages */}
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||||
|
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||||
|
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||||
|
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 1,
|
||||||
|
}}>
|
||||||
|
{hasMore && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||||
|
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||||
|
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<ChevronUp size={13} />
|
||||||
|
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, idx) => {
|
||||||
|
const own = isOwn(msg)
|
||||||
|
const prevMsg = messages[idx - 1]
|
||||||
|
const nextMsg = messages[idx + 1]
|
||||||
|
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||||
|
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||||
|
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||||
|
const showAvatar = !own && isLastInGroup
|
||||||
|
const bigEmoji = isEmojiOnly(msg.text)
|
||||||
|
const hasReply = msg.reply_text || msg.reply_to
|
||||||
|
// Deleted message placeholder
|
||||||
|
if (msg._deleted) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={msg.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||||
|
{formatDateSeparator(msg.created_at, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||||
|
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubble border radius — iMessage style tails
|
||||||
|
const br = own
|
||||||
|
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||||
|
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={msg.id}>
|
||||||
|
{/* Date separator */}
|
||||||
|
{showDate && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||||
|
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
{formatDateSeparator(msg.created_at, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||||
|
flexDirection: own ? 'row-reverse' : 'row',
|
||||||
|
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||||
|
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||||
|
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||||
|
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||||
|
}}>
|
||||||
|
{/* Avatar slot for others */}
|
||||||
|
{!own && (
|
||||||
|
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||||
|
{showAvatar && (
|
||||||
|
msg.user_avatar ? (
|
||||||
|
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{(msg.username || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||||
|
{/* Username for others at group start */}
|
||||||
|
{!own && isNewGroup && (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||||
|
{msg.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
onMouseEnter={() => setHoveredId(msg.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||||
|
onTouchEnd={e => {
|
||||||
|
const now = Date.now()
|
||||||
|
const lastTap = Number(e.currentTarget.dataset.lastTap) || 0
|
||||||
|
if (now - lastTap < 300 && canEdit) {
|
||||||
|
e.preventDefault()
|
||||||
|
const touch = e.changedTouches?.[0]
|
||||||
|
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||||
|
}
|
||||||
|
e.currentTarget.dataset.lastTap = String(now)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bigEmoji ? (
|
||||||
|
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||||
|
color: own ? '#fff' : 'var(--text-primary)',
|
||||||
|
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||||
|
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{/* Inline reply quote */}
|
||||||
|
{hasReply && (
|
||||||
|
<div style={{
|
||||||
|
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||||
|
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||||
|
fontSize: 12, lineHeight: 1.3,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||||
|
{msg.reply_username || ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{(msg.reply_text || '').slice(0, 80)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasReply ? (
|
||||||
|
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||||
|
) : <MessageText text={msg.text} />}
|
||||||
|
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||||
|
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover actions */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -14,
|
||||||
|
display: 'flex', gap: 2,
|
||||||
|
opacity: hoveredId === msg.id ? 1 : 0,
|
||||||
|
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||||
|
transition: 'opacity .1s',
|
||||||
|
...(own ? { left: -6 } : { right: -6 }),
|
||||||
|
}}>
|
||||||
|
<button onClick={() => setReplyTo(msg)} title={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,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||||
|
>
|
||||||
|
<Reply size={11} />
|
||||||
|
</button>
|
||||||
|
{own && 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,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reactions — iMessage style floating badge */}
|
||||||
|
{msg.reactions?.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||||
|
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||||
|
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||||
|
borderRadius: 99, background: 'var(--bg-card)',
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
{msg.reactions.map(r => {
|
||||||
|
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||||
|
return (
|
||||||
|
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamp — only on last message of group */}
|
||||||
|
{isLastInGroup && (
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||||
|
{formatTime(msg.created_at, is12h)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||||
|
import type { ChatReaction } from './CollabChat.types'
|
||||||
|
|
||||||
|
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||||
|
interface ReactionBadgeProps {
|
||||||
|
reaction: ChatReaction
|
||||||
|
currentUserId: number
|
||||||
|
onReact: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef(null)
|
||||||
|
const names = reaction.users.map(u => u.username).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button ref={ref} onClick={onReact}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setHover(true)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||||
|
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
background: 'transparent', transition: 'transform 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||||
|
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||||
|
</button>
|
||||||
|
{hover && names && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||||
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
{names}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { QUICK_REACTIONS } from './CollabChat.constants'
|
||||||
|
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||||
|
|
||||||
|
/* ── Reaction Quick Menu (right-click) ── */
|
||||||
|
interface ReactionMenuProps {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
onReact: (emoji: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
// Clamp to viewport
|
||||||
|
const menuWidth = 156
|
||||||
|
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{
|
||||||
|
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||||
|
}}>
|
||||||
|
{QUICK_REACTIONS.map(emoji => (
|
||||||
|
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||||
|
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||||
|
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||||
|
>
|
||||||
|
<TwemojiImg emoji={emoji} size={18} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { emojiToCodepoint } from './CollabChat.helpers'
|
||||||
|
|
||||||
|
export function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||||
|
const cp = emojiToCodepoint(emoji)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||||
|
alt={emoji}
|
||||||
|
draggable={false}
|
||||||
|
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||||
|
|
||||||
|
export const NOTE_COLORS = [
|
||||||
|
{ value: '#6366f1', label: 'Indigo' },
|
||||||
|
{ value: '#ef4444', label: 'Red' },
|
||||||
|
{ value: '#f59e0b', label: 'Amber' },
|
||||||
|
{ value: '#10b981', label: 'Emerald' },
|
||||||
|
{ value: '#3b82f6', label: 'Blue' },
|
||||||
|
{ value: '#8b5cf6', label: 'Violet' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Pure formatting helper for note timestamps. Falls back to translated
|
||||||
|
// relative labels for recent timestamps and a localized short date beyond a week.
|
||||||
|
export const formatTimestamp = (ts, t, locale) => {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - d.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
|
||||||
|
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`
|
||||||
|
const diffHrs = Math.floor(diffMins / 60)
|
||||||
|
if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago`
|
||||||
|
const diffDays = Math.floor(diffHrs / 24)
|
||||||
|
if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago`
|
||||||
|
return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
export interface NoteFile {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
original_name: string
|
||||||
|
mime_type: string
|
||||||
|
file_size?: number | null
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollabNote {
|
||||||
|
id: number
|
||||||
|
trip_id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
category: string
|
||||||
|
website: string | null
|
||||||
|
pinned: boolean
|
||||||
|
color: string | null
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
avatar: string | null
|
||||||
|
user_id: number
|
||||||
|
created_at: string
|
||||||
|
author?: { username: string; avatar: string | null }
|
||||||
|
user?: { username: string; avatar: string | null }
|
||||||
|
files?: NoteFile[]
|
||||||
|
// Wire field: collabService embeds note files as `attachments` (with url).
|
||||||
|
attachments?: NoteFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteAuthor {
|
||||||
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkBreaks from 'remark-breaks'
|
||||||
|
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||||
|
import { FONT } from './CollabNotes.constants'
|
||||||
|
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||||
|
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||||
|
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||||
|
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||||
|
import type { User } from '../../types'
|
||||||
|
|
||||||
|
// ── Note Card ───────────────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
onView: (note: CollabNote) => void
|
||||||
|
onPreviewFile: (file: NoteFile) => void
|
||||||
|
getCategoryColor: (category: string) => string
|
||||||
|
tripId: number
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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) }
|
||||||
|
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback(() => {
|
||||||
|
onUpdate(note.id, { pinned: !note.pinned })
|
||||||
|
}, [note.id, note.pinned, onUpdate])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
onDelete(note.id)
|
||||||
|
}, [note.id, onDelete])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
|
||||||
|
background: note.pinned ? `${color}08` : 'var(--bg-card)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontFamily: FONT,
|
||||||
|
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||||
|
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header bar — like reservation cards */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
|
||||||
|
background: `${color}0d`,
|
||||||
|
}}>
|
||||||
|
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
{note.category && (
|
||||||
|
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||||
|
{note.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Hover actions in header */}
|
||||||
|
{(
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 2,
|
||||||
|
}}>
|
||||||
|
{note.content && (
|
||||||
|
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||||
|
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)'}>
|
||||||
|
<Maximize2 size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{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>}
|
||||||
|
{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>}
|
||||||
|
{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>}
|
||||||
|
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||||
|
{/* Author avatar */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
|
||||||
|
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
|
||||||
|
<UserAvatar user={author} size={16} />
|
||||||
|
<div data-tip style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||||
|
whiteSpace: 'nowrap', zIndex: 10,
|
||||||
|
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
{author.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{note.content && (
|
||||||
|
<div className="collab-note-md" style={{
|
||||||
|
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||||
|
maxHeight: '4.5em', overflow: 'hidden',
|
||||||
|
wordBreak: 'break-word', fontFamily: FONT,
|
||||||
|
}}>
|
||||||
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Right: website + attachment thumbnails */}
|
||||||
|
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||||
|
{/* Website */}
|
||||||
|
{note.website && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
|
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||||
|
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Files */}
|
||||||
|
{(note.attachments || []).length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
|
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
||||||
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
|
return isImage ? (
|
||||||
|
<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)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||||
|
) : (
|
||||||
|
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||||
|
style={{
|
||||||
|
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||||
|
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||||
|
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||||
|
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||||
|
import { EditableCatName } from './CollabNotesEditableCatName'
|
||||||
|
|
||||||
|
// ── Category Settings Modal ──────────────────────────────────────────────────
|
||||||
|
interface CategorySettingsModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
categories: string[]
|
||||||
|
categoryColors: Record<string, string>
|
||||||
|
onSave: (colors: Record<string, string>) => void
|
||||||
|
onRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
|
||||||
|
const [localColors, setLocalColors] = useState({ ...categoryColors })
|
||||||
|
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
|
||||||
|
const [newCatName, setNewCatName] = useState('')
|
||||||
|
|
||||||
|
const handleColorChange = (cat, color) => {
|
||||||
|
setLocalColors(prev => ({ ...prev, [cat]: color }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCategory = () => {
|
||||||
|
if (!newCatName.trim() || localColors[newCatName.trim()]) return
|
||||||
|
setLocalColors(prev => ({ ...prev, [newCatName.trim()]: NOTE_COLORS[Object.keys(prev).length % NOTE_COLORS.length].value }))
|
||||||
|
setNewCatName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCategory = (cat) => {
|
||||||
|
setLocalColors(prev => { const n = { ...prev }; delete n[cat]; return n })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameCategory = (oldName, newName) => {
|
||||||
|
if (!newName.trim() || newName.trim() === oldName || localColors[newName.trim()]) return
|
||||||
|
// Track rename for saving to DB later
|
||||||
|
const originalName = Object.entries(renames).find(([, v]) => v === oldName)?.[0] || oldName
|
||||||
|
setRenames(prev => ({ ...prev, [originalName]: newName.trim() }))
|
||||||
|
setLocalColors(prev => {
|
||||||
|
const n = {}
|
||||||
|
for (const [k, v] of Object.entries(prev)) {
|
||||||
|
n[k === oldName ? newName.trim() : k] = v
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Apply renames to notes in DB
|
||||||
|
for (const [oldName, newName] of Object.entries(renames)) {
|
||||||
|
if (oldName !== newName) await onRenameCategory(oldName, newName)
|
||||||
|
}
|
||||||
|
await onSave(localColors)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge existing categories from notes with saved colors
|
||||||
|
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||||
|
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||||
|
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||||
|
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories list */}
|
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{allCats.length === 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||||
|
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{allCats.map(cat => (
|
||||||
|
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{NOTE_COLORS.map(c => (
|
||||||
|
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||||
|
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
|
||||||
|
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||||
|
outlineOffset: 1, transition: 'transform 0.1s',
|
||||||
|
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Category name — editable */}
|
||||||
|
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
|
||||||
|
{/* Delete */}
|
||||||
|
<button onClick={() => handleRemoveCategory(cat)} style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add new */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||||
|
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||||
|
placeholder={t('collab.notes.newCategory')}
|
||||||
|
style={{
|
||||||
|
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
|
||||||
|
fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||||
|
}} />
|
||||||
|
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||||
|
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||||
|
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
|
||||||
|
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<button onClick={handleSave} style={{
|
||||||
|
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
|
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||||
|
}}>
|
||||||
|
{t('collab.notes.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface EditableCatNameProps {
|
||||||
|
name: string
|
||||||
|
onRename: (newName: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(name)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setEditing(false)
|
||||||
|
if (value.trim() && value.trim() !== name) onRename(value.trim())
|
||||||
|
else setValue(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
|
||||||
|
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
|
||||||
|
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||||
|
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||||
|
title="Click to rename">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, ExternalLink, Loader2 } from 'lucide-react'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import type { NoteFile } from './CollabNotes.types'
|
||||||
|
|
||||||
|
// ── File Preview Portal ─────────────────────────────────────────────────────
|
||||||
|
interface FilePreviewPortalProps {
|
||||||
|
file: NoteFile | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||||
|
const [authUrl, setAuthUrl] = useState('')
|
||||||
|
const rawUrl = file?.url || ''
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthUrl('')
|
||||||
|
if (!rawUrl) return
|
||||||
|
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||||
|
}, [rawUrl])
|
||||||
|
|
||||||
|
if (!file) return null
|
||||||
|
const isImage = file.mime_type?.startsWith('image/')
|
||||||
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
|
const isTxt = file.mime_type?.startsWith('text/')
|
||||||
|
|
||||||
|
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||||
|
|
||||||
|
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()}>
|
||||||
|
{authUrl
|
||||||
|
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||||
|
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||||
|
}
|
||||||
|
<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 }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Document viewer — card with header */
|
||||||
|
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 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 }}>
|
||||||
|
<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={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)' }}>
|
||||||
|
<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 }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Plus, X } from 'lucide-react'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { FONT } from './CollabNotes.constants'
|
||||||
|
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||||
|
import type { CollabNote } from './CollabNotes.types'
|
||||||
|
|
||||||
|
// ── New Note Modal (portal to body) ─────────────────────────────────────────
|
||||||
|
interface NoteFormModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
|
||||||
|
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||||
|
existingCategories: string[]
|
||||||
|
categoryColors: Record<string, string>
|
||||||
|
getCategoryColor: (category: string) => string
|
||||||
|
note: CollabNote | null
|
||||||
|
tripId: number
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(note?.title || '')
|
||||||
|
const [content, setContent] = useState(note?.content || '')
|
||||||
|
const [category, setCategory] = useState(note?.category || allCategories[0] || '')
|
||||||
|
const [website, setWebsite] = useState(note?.website || '')
|
||||||
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const [existingAttachments, setExistingAttachments] = useState(note?.attachments || [])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const fileRef = useRef(null)
|
||||||
|
|
||||||
|
const finalCategory = category
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
category: finalCategory || null,
|
||||||
|
color: getCategoryColor(finalCategory),
|
||||||
|
website: website.trim() || null,
|
||||||
|
_pendingFiles: pendingFiles,
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAttachment = async (fileId) => {
|
||||||
|
if (onDeleteFile && note) {
|
||||||
|
await onDeleteFile(note.id, fileId)
|
||||||
|
setExistingAttachments(prev => prev.filter(a => a.id !== fileId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = title.trim() && !submitting
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
WebkitBackdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: 16,
|
||||||
|
fontFamily: FONT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
borderRadius: 16,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
border: '1px solid var(--border-faint)',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onPaste={e => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) setPendingFiles(prev => [...prev, file])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{/* Modal header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px 12px',
|
||||||
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: FONT,
|
||||||
|
}}>
|
||||||
|
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
padding: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal body */}
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: FONT,
|
||||||
|
}}>
|
||||||
|
{t('collab.notes.title')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
placeholder={t('collab.notes.titlePlaceholder')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: FONT,
|
||||||
|
}}>
|
||||||
|
{t('collab.notes.contentPlaceholder')}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={e => setContent(e.target.value)}
|
||||||
|
placeholder={t('collab.notes.contentPlaceholder')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: 180,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category pills */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||||
|
{t('collab.notes.category')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{allCategories.map(cat => {
|
||||||
|
const c = getCategoryColor(cat)
|
||||||
|
const active = category === cat
|
||||||
|
return (
|
||||||
|
<button key={cat} type="button" onClick={() => setCategory(cat)}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||||
|
{t('collab.notes.website')}
|
||||||
|
</div>
|
||||||
|
<input value={website} onChange={e => setWebsite(e.target.value)}
|
||||||
|
placeholder={t('collab.notes.websitePlaceholder')}
|
||||||
|
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File attachments */}
|
||||||
|
{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 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 => {
|
||||||
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
|
return (
|
||||||
|
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||||
|
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||||
|
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* New pending files */}
|
||||||
|
{pendingFiles.map((f, i) => (
|
||||||
|
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
|
||||||
|
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 99,
|
||||||
|
padding: '7px 14px',
|
||||||
|
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||||
|
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: FONT,
|
||||||
|
border: 'none',
|
||||||
|
cursor: canSubmit ? 'pointer' : 'default',
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { FONT } from './CollabNotes.constants'
|
||||||
|
import type { NoteAuthor } from './CollabNotes.types'
|
||||||
|
|
||||||
|
// ── Avatar ──────────────────────────────────────────────────────────────────
|
||||||
|
interface UserAvatarProps {
|
||||||
|
user: NoteAuthor | null
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||||
|
if (!user) return null
|
||||||
|
if (user.avatar) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.username}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const initials = (user.username || '?').slice(0, 1)
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: size * 0.45,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
flexShrink: 0,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontFamily: FONT,
|
||||||
|
}}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
import { collabApi } from '../../api/client'
|
||||||
|
|
||||||
|
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
|
||||||
|
const ogCache = {}
|
||||||
|
|
||||||
|
interface WebsiteThumbnailProps {
|
||||||
|
url: string
|
||||||
|
tripId: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
|
||||||
|
const [data, setData] = useState(ogCache[url] || null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ogCache[url]) { setData(ogCache[url]); return }
|
||||||
|
collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true))
|
||||||
|
}, [url, tripId])
|
||||||
|
|
||||||
|
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" title={data?.title || url}
|
||||||
|
style={{
|
||||||
|
width: 48, height: 48, borderRadius: 8, cursor: 'pointer', overflow: 'hidden',
|
||||||
|
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||||
|
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||||
|
{data?.image && !failed ? (
|
||||||
|
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ExternalLink size={14} color="var(--text-muted)" />
|
||||||
|
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||||
|
{domain}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,11 +17,7 @@ function useIsDesktop(breakpoint = 1024) {
|
|||||||
return isDesktop
|
return isDesktop
|
||||||
}
|
}
|
||||||
|
|
||||||
const card = {
|
const cardClass = 'flex flex-col bg-surface-card rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
|
||||||
overflow: 'hidden', minHeight: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TripMember {
|
interface TripMember {
|
||||||
id: number
|
id: number
|
||||||
@@ -88,7 +84,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
|||||||
// Only chat
|
// Only chat
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
<CollabChat tripId={tripId} currentUser={user} />
|
<CollabChat tripId={tripId} currentUser={user} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,19 +95,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
|||||||
// Chat left (380px) + right panels
|
// Chat left (380px) + right panels
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
||||||
<CollabChat tripId={tripId} currentUser={user} />
|
<CollabChat tripId={tripId} currentUser={user} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
{rightPanels.length === 1 && (
|
{rightPanels.length === 1 && (
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||||
<div key={p} style={{ ...card, flex: 1 }}>
|
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
@@ -119,14 +115,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
|||||||
))}
|
))}
|
||||||
{rightPanels.length === 3 && (
|
{rightPanels.length === 3 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
<CollabNotes tripId={tripId} currentUser={user} />
|
<CollabNotes tripId={tripId} currentUser={user} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
<CollabPolls tripId={tripId} currentUser={user} />
|
<CollabPolls tripId={tripId} currentUser={user} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
<WhatsNextWidget tripMembers={tripMembers} />
|
<WhatsNextWidget tripMembers={tripMembers} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +138,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
|||||||
if (panels.length === 1) {
|
if (panels.length === 1) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<div style={{ ...card, flex: 1 }}>
|
<div className={cardClass} style={{ flex: 1 }}>
|
||||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
@@ -154,7 +150,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
{panels.map(p => (
|
{panels.map(p => (
|
||||||
<div key={p} style={{ ...card, flex: 1 }}>
|
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CollabPolls', () => {
|
describe('CollabPolls', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
|||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
@@ -78,7 +79,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
|||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
|
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
|
||||||
onClose()
|
onClose()
|
||||||
} catch {} finally { setSubmitting(false) }
|
} catch {} finally { setSubmitting(false) }
|
||||||
}
|
}
|
||||||
@@ -230,7 +231,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
|||||||
<Clock size={8} /> {remaining}
|
<Clock size={8} /> {remaining}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{poll.multiple_choice && (
|
{poll.multi_choice && (
|
||||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||||
{t('collab.polls.multiChoice')}
|
{t('collab.polls.multiChoice')}
|
||||||
</span>
|
</span>
|
||||||
@@ -305,7 +306,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
|||||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||||
}}>
|
}}>
|
||||||
{typeof opt === 'string' ? opt : opt.label || opt}
|
{typeof opt === 'string' ? opt : opt.text}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Voter avatars */}
|
{/* Voter avatars */}
|
||||||
@@ -342,6 +343,7 @@ interface CollabPollsProps {
|
|||||||
|
|
||||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('collab_edit', trip)
|
const canEdit = can('collab_edit', trip)
|
||||||
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreate = useCallback(async (data) => {
|
const handleCreate = useCallback(async (data) => {
|
||||||
const result = await collabApi.createPoll(tripId, data)
|
try {
|
||||||
const created = result.poll || result
|
const result = await collabApi.createPoll(tripId, data)
|
||||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
const created = result.poll || result
|
||||||
setShowForm(false)
|
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||||
}, [tripId])
|
setShowForm(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||||
try {
|
try {
|
||||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||||
const updated = result.poll || result
|
const updated = result.poll || result
|
||||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||||
} catch {}
|
} catch {
|
||||||
}, [tripId])
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
const handleClose = useCallback(async (pollId) => {
|
const handleClose = useCallback(async (pollId) => {
|
||||||
try {
|
try {
|
||||||
await collabApi.closePoll(tripId, pollId)
|
await collabApi.closePoll(tripId, pollId)
|
||||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||||
} catch {}
|
} catch {
|
||||||
}, [tripId])
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
const handleDelete = useCallback(async (pollId) => {
|
const handleDelete = useCallback(async (pollId) => {
|
||||||
try {
|
try {
|
||||||
await collabApi.deletePoll(tripId, pollId)
|
await collabApi.deletePoll(tripId, pollId)
|
||||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||||
} catch {}
|
} catch {
|
||||||
}, [tripId])
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
||||||
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
||||||
|
|||||||
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
|
|||||||
notes: null,
|
notes: null,
|
||||||
place: {
|
place: {
|
||||||
id,
|
id,
|
||||||
trip_id: 1,
|
|
||||||
name: `Place ${id}`,
|
name: `Place ${id}`,
|
||||||
description: null,
|
description: null,
|
||||||
lat: 0,
|
lat: 0,
|
||||||
lng: 0,
|
lng: 0,
|
||||||
address: null,
|
address: null,
|
||||||
category_id: null,
|
category_id: null,
|
||||||
icon: null,
|
|
||||||
price: null,
|
price: null,
|
||||||
|
currency: null,
|
||||||
image_url: null,
|
image_url: null,
|
||||||
google_place_id: null,
|
google_place_id: null,
|
||||||
osm_id: null,
|
|
||||||
route_geometry: null,
|
|
||||||
place_time: null,
|
place_time: null,
|
||||||
end_time: null,
|
end_time: null,
|
||||||
created_at: '2025-01-01T00:00:00.000Z',
|
duration_minutes: 60,
|
||||||
|
notes: null,
|
||||||
|
transport_mode: 'walking',
|
||||||
|
website: null,
|
||||||
|
phone: null,
|
||||||
...placeOverrides,
|
...placeOverrides,
|
||||||
},
|
},
|
||||||
participants,
|
participants,
|
||||||
@@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(10, { place_time: '08:00' })],
|
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||||
},
|
},
|
||||||
@@ -95,7 +96,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||||
},
|
},
|
||||||
@@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(21, { name: 'Museum' })],
|
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||||
},
|
},
|
||||||
@@ -118,7 +119,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||||
},
|
},
|
||||||
@@ -130,7 +131,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||||
},
|
},
|
||||||
@@ -142,7 +143,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||||
},
|
},
|
||||||
@@ -153,7 +154,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||||
},
|
},
|
||||||
@@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||||
},
|
},
|
||||||
@@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
trip_id: 1,
|
trip_id: 1,
|
||||||
date: getFutureDate(i + 1),
|
date: getFutureDate(i + 1),
|
||||||
title: null,
|
title: null,
|
||||||
order: i,
|
day_number: i,
|
||||||
assignments: [],
|
assignments: [],
|
||||||
notes_items: [],
|
notes_items: [],
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||||
},
|
},
|
||||||
@@ -218,7 +219,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||||
},
|
},
|
||||||
@@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||||
},
|
},
|
||||||
@@ -241,7 +242,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
|
|
||||||
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [
|
'1': [
|
||||||
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||||
@@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => {
|
|||||||
if (now.getHours() > 0) {
|
if (now.getHours() > 0) {
|
||||||
const pastTime = '00:01' // Very early — will be past for most of the day
|
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||||
seedStore(useTripStore, {
|
seedStore(useTripStore, {
|
||||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
assignments: {
|
assignments: {
|
||||||
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function formatDayLabel(date, t, locale) {
|
|||||||
interface TripMember {
|
interface TripMember {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from '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 { useToast } from '../shared/Toast'
|
||||||
|
|
||||||
|
export function useCollabChat(tripId: any, currentUser: any) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
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)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [replyTo, setReplyTo] = useState(null)
|
||||||
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
|
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||||
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||||
|
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const messagesRef = useRef(messages)
|
||||||
|
messagesRef.current = messages
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
const textareaRef = useRef(null)
|
||||||
|
const emojiBtnRef = useRef(null)
|
||||||
|
const isAtBottom = useRef(true)
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const checkAtBottom = useCallback(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* ── load messages ── */
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
collabApi.getMessages(tripId).then(data => {
|
||||||
|
if (cancelled) return
|
||||||
|
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||||
|
setMessages(msgs)
|
||||||
|
setHasMore(msgs.length >= 100)
|
||||||
|
setLoading(false)
|
||||||
|
setTimeout(() => scrollToBottom(), 30)
|
||||||
|
}).catch(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [tripId, scrollToBottom])
|
||||||
|
|
||||||
|
/* ── load more ── */
|
||||||
|
const handleLoadMore = useCallback(async () => {
|
||||||
|
if (loadingMore || messages.length === 0) return
|
||||||
|
setLoadingMore(true)
|
||||||
|
const el = scrollRef.current
|
||||||
|
const prevHeight = el ? el.scrollHeight : 0
|
||||||
|
try {
|
||||||
|
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
||||||
|
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||||
|
if (older.length === 0) { setHasMore(false) }
|
||||||
|
else {
|
||||||
|
setMessages(prev => [...older, ...prev])
|
||||||
|
setHasMore(older.length >= 100)
|
||||||
|
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
||||||
|
}
|
||||||
|
} catch {} finally { setLoadingMore(false) }
|
||||||
|
}, [tripId, loadingMore, messages])
|
||||||
|
|
||||||
|
/* ── websocket ── */
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
||||||
|
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
||||||
|
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
||||||
|
}
|
||||||
|
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
||||||
|
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
||||||
|
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
||||||
|
}
|
||||||
|
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
||||||
|
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addListener(handler)
|
||||||
|
return () => removeListener(handler)
|
||||||
|
}, [tripId, scrollToBottom])
|
||||||
|
|
||||||
|
/* ── auto-resize textarea ── */
|
||||||
|
const handleTextChange = useCallback((e) => {
|
||||||
|
setText(e.target.value)
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (ta) {
|
||||||
|
ta.style.height = 'auto'
|
||||||
|
const h = Math.min(ta.scrollHeight, 100)
|
||||||
|
ta.style.height = h + 'px'
|
||||||
|
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* ── send ── */
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
const body = text.trim()
|
||||||
|
if (!body || sending) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const payload: { text: string; reply_to?: number } = { text: body }
|
||||||
|
if (replyTo) payload.reply_to = replyTo.id
|
||||||
|
const data = await collabApi.sendMessage(tripId, payload)
|
||||||
|
if (data?.message) {
|
||||||
|
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||||
|
}
|
||||||
|
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||||
|
isAtBottom.current = true
|
||||||
|
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||||
|
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||||
|
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||||
|
}, [handleSend])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (msgId) => {
|
||||||
|
const msg = messages.find(m => m.id === msgId)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||||
|
})
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await collabApi.deleteMessage(tripId, msgId)
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||||
|
}, 400)
|
||||||
|
deleteTimersRef.current.push(timer)
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
|
const handleReact = useCallback(async (msgId, emoji) => {
|
||||||
|
setReactMenu(null)
|
||||||
|
try {
|
||||||
|
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
}, [tripId, toast, t])
|
||||||
|
|
||||||
|
const handleEmojiSelect = useCallback((emoji) => {
|
||||||
|
setText(prev => prev + emoji)
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
||||||
|
|
||||||
|
// Check if message is only emoji (1-3 emojis, no other text)
|
||||||
|
const isEmojiOnly = (text) => {
|
||||||
|
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[️]?(?:\p{Extended_Pictographic}[️]?)*){1,3}$/u
|
||||||
|
return emojiRegex.test(text.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
|
|
||||||
const CURRENCIES = [
|
|
||||||
'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 }))
|
|
||||||
|
|
||||||
export default function CurrencyWidget() {
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
|
||||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
|
||||||
const [amount, setAmount] = useState('100')
|
|
||||||
const [rate, setRate] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const fetchRate = useCallback(async () => {
|
|
||||||
if (from === to) { setRate(1); return }
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
|
||||||
const data = await resp.json()
|
|
||||||
setRate(data.rates?.[to] || null)
|
|
||||||
} catch { setRate(null) }
|
|
||||||
finally { setLoading(false) }
|
|
||||||
}, [from, to])
|
|
||||||
|
|
||||||
useEffect(() => { fetchRate() }, [fetchRate])
|
|
||||||
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
|
||||||
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
|
||||||
|
|
||||||
const swap = () => { setFrom(to); setTo(from) }
|
|
||||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
|
||||||
const formatNumber = (num) => {
|
|
||||||
if (!num || num === '—') return '—'
|
|
||||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
}
|
|
||||||
const result = rawResult
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
|
||||||
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
|
||||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount */}
|
|
||||||
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={amount}
|
|
||||||
onChange={e => setAmount(e.target.value)}
|
|
||||||
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* From / Swap / To */}
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
|
||||||
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
|
||||||
</div>
|
|
||||||
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
<ArrowRightLeft size={13} />
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
|
||||||
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Result */}
|
|
||||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
|
||||||
</p>
|
|
||||||
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { render, screen } from '../../../tests/helpers/render'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import TimezoneWidget from './TimezoneWidget'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetAllStores()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
localStorage.clear()
|
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('TimezoneWidget', () => {
|
|
||||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
expect(document.body).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
|
||||||
expect(timeElements.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
|
||||||
localStorage.clear()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
|
||||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('New York')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
// Open add panel
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
// Find and click Berlin in the popular zones list
|
|
||||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
|
||||||
await user.click(berlinButton)
|
|
||||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
|
||||||
// Panel should be closed
|
|
||||||
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
// Open add panel
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
// Type label and timezone
|
|
||||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
|
||||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
|
||||||
await user.type(labelInput, 'My City')
|
|
||||||
await user.type(tzInput, 'Europe/Paris')
|
|
||||||
// Click Add
|
|
||||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
|
||||||
await user.click(addButton)
|
|
||||||
expect(await screen.findByText('My City')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
|
||||||
await user.type(tzInput, 'Invalid/Timezone')
|
|
||||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
|
||||||
await user.click(addButton)
|
|
||||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
// Default zones include New York (America/New_York)
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
|
||||||
await user.type(tzInput, 'America/New_York')
|
|
||||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
|
||||||
await user.click(addButton)
|
|
||||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
|
||||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
|
||||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
|
||||||
// Remove buttons for New York and Tokyo come after the Plus button
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
|
||||||
await user.click(allButtons[1])
|
|
||||||
expect(screen.queryByText('New York')).toBeNull()
|
|
||||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
|
||||||
await user.click(berlinButton)
|
|
||||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
|
||||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<TimezoneWidget />)
|
|
||||||
const allButtons = screen.getAllByRole('button')
|
|
||||||
await user.click(allButtons[0])
|
|
||||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
|
||||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
|
||||||
await user.type(labelInput, 'Singapore')
|
|
||||||
await user.type(tzInput, 'Asia/Singapore')
|
|
||||||
await user.keyboard('{Enter}')
|
|
||||||
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Clock, Plus, X } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
|
|
||||||
const POPULAR_ZONES = [
|
|
||||||
{ label: 'New York', tz: 'America/New_York' },
|
|
||||||
{ label: 'London', tz: 'Europe/London' },
|
|
||||||
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
|
||||||
{ label: 'Paris', tz: 'Europe/Paris' },
|
|
||||||
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
|
||||||
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
|
||||||
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
|
||||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
|
||||||
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
|
||||||
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
|
||||||
{ label: 'Chicago', tz: 'America/Chicago' },
|
|
||||||
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
|
||||||
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
|
||||||
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
|
||||||
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
|
||||||
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
|
||||||
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
|
||||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTime(tz, locale, is12h) {
|
|
||||||
try {
|
|
||||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
|
||||||
} catch { return '—' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOffset(tz) {
|
|
||||||
try {
|
|
||||||
const now = new Date()
|
|
||||||
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
|
||||||
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
|
||||||
const diff = (remote - local) / 3600000
|
|
||||||
const sign = diff >= 0 ? '+' : ''
|
|
||||||
return `${sign}${diff}h`
|
|
||||||
} catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TimezoneWidget() {
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
|
||||||
const [zones, setZones] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('dashboard_timezones')
|
|
||||||
return saved ? JSON.parse(saved) : [
|
|
||||||
{ label: 'New York', tz: 'America/New_York' },
|
|
||||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
const [now, setNow] = useState(Date.now())
|
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
|
||||||
const [customLabel, setCustomLabel] = useState('')
|
|
||||||
const [customTz, setCustomTz] = useState('')
|
|
||||||
const [customError, setCustomError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
|
||||||
return () => clearInterval(i)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
|
||||||
}, [zones])
|
|
||||||
|
|
||||||
const isValidTz = (tz: string) => {
|
|
||||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const addCustomZone = () => {
|
|
||||||
const tz = customTz.trim()
|
|
||||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
|
||||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
|
||||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
|
||||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
|
||||||
setZones([...zones, { label, tz }])
|
|
||||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addZone = (zone) => {
|
|
||||||
if (!zones.find(z => z.tz === zone.tz)) {
|
|
||||||
setZones([...zones, zone])
|
|
||||||
}
|
|
||||||
setShowAdd(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
|
||||||
|
|
||||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
|
||||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
||||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
|
||||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
|
||||||
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
|
||||||
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
|
||||||
<Plus size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Local time */}
|
|
||||||
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
|
||||||
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zone list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{zones.map(z => (
|
|
||||||
<div key={z.tz} className="flex items-center justify-between group">
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
|
||||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add zone dropdown */}
|
|
||||||
{showAdd && (
|
|
||||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
{/* Custom timezone */}
|
|
||||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
|
||||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
|
||||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
|
||||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
|
||||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
|
||||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
|
||||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
|
||||||
<button onClick={addCustomZone}
|
|
||||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
|
||||||
{t('dashboard.timezoneCustomAdd')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Popular zones */}
|
|
||||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
|
||||||
<button key={z.tz} onClick={() => addZone(z)}
|
|
||||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
||||||
<span className="font-medium">{z.label}</span>
|
|
||||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { FileText, FileImage, File, Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
|
import { downloadFile } from '../../utils/fileDownload'
|
||||||
|
|
||||||
|
export function isImage(mimeType?: string | null) {
|
||||||
|
if (!mimeType) return false
|
||||||
|
return mimeType.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileIcon(mimeType?: string | null) {
|
||||||
|
if (!mimeType) return File
|
||||||
|
if (mimeType === 'application/pdf') return FileText
|
||||||
|
if (isImage(mimeType)) return FileImage
|
||||||
|
return File
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes?: number | null) {
|
||||||
|
if (!bytes) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDownload(url: string, filename: string) {
|
||||||
|
downloadFile(url, filename).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transportIcon(type: string) {
|
||||||
|
if (type === 'train') return Train
|
||||||
|
if (type === 'car') return Car
|
||||||
|
if (type === 'cruise') return Ship
|
||||||
|
return Plane
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import type { TripFile } from '../../types';
|
||||||
import FileManager from './FileManager';
|
import FileManager from './FileManager';
|
||||||
|
|
||||||
// Mock getAuthUrl
|
// Mock getAuthUrl
|
||||||
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
|
|||||||
|
|
||||||
import { filesApi } from '../../api/client';
|
import { filesApi } from '../../api/client';
|
||||||
|
|
||||||
const buildFile = (overrides = {}) => ({
|
const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
|
trip_id: 1,
|
||||||
|
filename: 'report.pdf',
|
||||||
original_name: 'report.pdf',
|
original_name: 'report.pdf',
|
||||||
mime_type: 'application/pdf',
|
mime_type: 'application/pdf',
|
||||||
file_size: 51200,
|
file_size: 51200,
|
||||||
created_at: '2025-01-10T08:00:00Z',
|
created_at: '2025-01-10T08:00:00Z',
|
||||||
url: '/uploads/trips/1/report.pdf',
|
url: '/uploads/trips/1/report.pdf',
|
||||||
starred: false,
|
starred: 0,
|
||||||
deleted_at: null,
|
deleted_at: null,
|
||||||
place_id: null,
|
place_id: null,
|
||||||
reservation_id: null,
|
reservation_id: null,
|
||||||
day_id: null,
|
|
||||||
uploaded_by: 1,
|
uploaded_by: 1,
|
||||||
uploader_name: 'Alice',
|
uploaded_by_name: 'Alice',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,8 +322,8 @@ describe('FileManager', () => {
|
|||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
||||||
const files = [
|
const files = [
|
||||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
|
buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }),
|
||||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
|
buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }),
|
||||||
];
|
];
|
||||||
render(<FileManager {...defaultProps} files={files} />);
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -388,7 +390,7 @@ describe('FileManager', () => {
|
|||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
const reservation = buildReservation({ id: 20, title: 'Hotel Paris' });
|
||||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -418,7 +420,7 @@ describe('FileManager', () => {
|
|||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
const reservation = buildReservation({ id: 20, title: 'Train Ticket' });
|
||||||
const file = buildFile({ id: 1 });
|
const file = buildFile({ id: 1 });
|
||||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -436,7 +438,7 @@ describe('FileManager', () => {
|
|||||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||||
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||||
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
const reservation = buildReservation({ id: 20, title: 'Airbnb' });
|
||||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -527,7 +529,7 @@ describe('FileManager', () => {
|
|||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
const reservation = buildReservation({ id: 20, title: 'Museum Pass' });
|
||||||
// File already has reservation_id set to 20
|
// File already has reservation_id set to 20
|
||||||
const file = buildFile({ id: 1, reservation_id: 20 });
|
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,968 +1,29 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import { useFileManager, type FileManagerProps } from './useFileManager'
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { ImageLightbox } from './FileManagerImageLightbox'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { AssignModal } from './FileManagerAssignModal'
|
||||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
|
||||||
import { useToast } from '../shared/Toast'
|
import { FileManagerToolbar } from './FileManagerToolbar'
|
||||||
import { useTranslation } from '../../i18n'
|
import { TrashView } from './FileManagerTrashView'
|
||||||
import { filesApi } from '../../api/client'
|
import { FilesView } from './FileManagerFilesView'
|
||||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
|
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
|
||||||
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
|
|
||||||
|
|
||||||
function isImage(mimeType) {
|
|
||||||
if (!mimeType) return false
|
|
||||||
return mimeType.startsWith('image/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileIcon(mimeType) {
|
|
||||||
if (!mimeType) return File
|
|
||||||
if (mimeType === 'application/pdf') return FileText
|
|
||||||
if (isImage(mimeType)) return FileImage
|
|
||||||
return File
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (!bytes) return ''
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerDownload(url: string, filename: string) {
|
|
||||||
downloadFile(url, filename).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateWithLocale(dateStr, locale) {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
} catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image lightbox with gallery navigation
|
|
||||||
interface ImageLightboxProps {
|
|
||||||
files: (TripFile & { url: string })[]
|
|
||||||
initialIndex: number
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [index, setIndex] = useState(initialIndex)
|
|
||||||
const [imgSrc, setImgSrc] = useState('')
|
|
||||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
|
||||||
const file = files[index]
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setImgSrc('')
|
|
||||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
|
||||||
}, [file?.url])
|
|
||||||
|
|
||||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
|
||||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
if (e.key === 'ArrowLeft') goPrev()
|
|
||||||
if (e.key === 'ArrowRight') goNext()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
return () => window.removeEventListener('keydown', handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!file) return null
|
|
||||||
|
|
||||||
const hasPrev = index > 0
|
|
||||||
const hasNext = index < files.length - 1
|
|
||||||
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
|
||||||
<button onClick={e => { e.stopPropagation(); onClick() }}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
|
||||||
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
||||||
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
|
||||||
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
|
||||||
</button>
|
|
||||||
) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
|
||||||
onTouchEnd={e => {
|
|
||||||
if (touchStart === null) return
|
|
||||||
const diff = e.changedTouches[0].clientX - touchStart
|
|
||||||
if (diff > 60) goPrev()
|
|
||||||
else if (diff < -60) goNext()
|
|
||||||
setTouchStart(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
|
||||||
{file.original_name}
|
|
||||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
|
||||||
title={t('files.openTab')}>
|
|
||||||
<ExternalLink size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
|
||||||
title={t('files.download') || 'Download'}>
|
|
||||||
<Download size={16} />
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main image + nav */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
||||||
{navBtn('left', goPrev, hasPrev)}
|
|
||||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
|
||||||
{navBtn('right', goNext, hasNext)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail strip */}
|
|
||||||
{files.length > 1 && (
|
|
||||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
|
||||||
{files.map((f, i) => (
|
|
||||||
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
|
||||||
const [src, setSrc] = useState('')
|
|
||||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
|
||||||
return (
|
|
||||||
<button onClick={onClick} style={{
|
|
||||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
|
||||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
|
||||||
}}>
|
|
||||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }>
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
fontSize: 10.5, color: '#4b5563',
|
|
||||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
|
||||||
borderRadius: 6, padding: '2px 7px',
|
|
||||||
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const onEnter = () => {
|
|
||||||
if (ref.current) {
|
|
||||||
const rect = ref.current.getBoundingClientRect()
|
|
||||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
|
||||||
}
|
|
||||||
setHover(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
|
||||||
style={{
|
|
||||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
|
||||||
cursor: 'default',
|
|
||||||
}}>
|
|
||||||
{avatarUrl
|
|
||||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: name?.[0]?.toUpperCase()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{hover && ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
|
||||||
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
|
||||||
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
{name}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerProps {
|
|
||||||
files?: TripFile[]
|
|
||||||
onUpload: (fd: FormData) => Promise<any>
|
|
||||||
onDelete: (fileId: number) => Promise<void>
|
|
||||||
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
|
||||||
places: Place[]
|
|
||||||
days?: Day[]
|
|
||||||
assignments?: AssignmentsMap
|
|
||||||
reservations?: Reservation[]
|
|
||||||
tripId: number
|
|
||||||
allowedFileTypes: Record<string, string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [filterType, setFilterType] = useState('all')
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
|
||||||
const [showTrash, setShowTrash] = useState(false)
|
|
||||||
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 () => {
|
|
||||||
setLoadingTrash(true)
|
|
||||||
try {
|
|
||||||
const data = await filesApi.list(tripId, true)
|
|
||||||
setTrashFiles(data.files || [])
|
|
||||||
} catch { /* */ }
|
|
||||||
setLoadingTrash(false)
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const toggleTrash = useCallback(() => {
|
|
||||||
if (!showTrash) loadTrash()
|
|
||||||
setShowTrash(v => !v)
|
|
||||||
}, [showTrash, loadTrash])
|
|
||||||
|
|
||||||
const refreshFiles = useCallback(async () => {
|
|
||||||
if (onUpdate) onUpdate(0, {} as any)
|
|
||||||
}, [onUpdate])
|
|
||||||
|
|
||||||
const handleStar = async (fileId: number) => {
|
|
||||||
try {
|
|
||||||
await filesApi.toggleStar(tripId, fileId)
|
|
||||||
refreshFiles()
|
|
||||||
} catch { /* */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRestore = async (fileId: number) => {
|
|
||||||
try {
|
|
||||||
await filesApi.restore(tripId, fileId)
|
|
||||||
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
|
||||||
refreshFiles()
|
|
||||||
toast.success(t('files.toast.restored'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.restoreError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePermanentDelete = async (fileId: number) => {
|
|
||||||
if (!confirm(t('files.confirm.permanentDelete'))) return
|
|
||||||
try {
|
|
||||||
await filesApi.permanentDelete(tripId, fileId)
|
|
||||||
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
|
||||||
toast.success(t('files.toast.deleted'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEmptyTrash = async () => {
|
|
||||||
if (!confirm(t('files.confirm.emptyTrash'))) return
|
|
||||||
try {
|
|
||||||
await filesApi.emptyTrash(tripId)
|
|
||||||
setTrashFiles([])
|
|
||||||
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
|
|
||||||
|
|
||||||
const onDrop = useCallback(async (acceptedFiles) => {
|
|
||||||
if (acceptedFiles.length === 0) return
|
|
||||||
setUploading(true)
|
|
||||||
const uploadedIds: number[] = []
|
|
||||||
try {
|
|
||||||
for (const file of acceptedFiles) {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
const result = await onUpload(formData)
|
|
||||||
const fileObj = result?.file || result
|
|
||||||
if (fileObj?.id) uploadedIds.push(fileObj.id)
|
|
||||||
}
|
|
||||||
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
|
||||||
// Open assign modal for the last uploaded file
|
|
||||||
const lastId = uploadedIds[uploadedIds.length - 1]
|
|
||||||
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
|
||||||
setAssignFileId(lastId)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.uploadError'))
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}, [onUpload, toast, t, places, reservations])
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
onDrop,
|
|
||||||
maxSize: 50 * 1024 * 1024,
|
|
||||||
noClick: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
|
||||||
if (!can('file_upload', trip)) return
|
|
||||||
const items = e.clipboardData?.items
|
|
||||||
if (!items) return
|
|
||||||
const pastedFiles = []
|
|
||||||
for (const item of Array.from(items)) {
|
|
||||||
if (item.kind === 'file') {
|
|
||||||
const file = item.getAsFile()
|
|
||||||
if (file) pastedFiles.push(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pastedFiles.length > 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
onDrop(pastedFiles)
|
|
||||||
}
|
|
||||||
}, [onDrop])
|
|
||||||
|
|
||||||
const filteredFiles = files.filter(f => {
|
|
||||||
if (filterType === 'starred') return !!f.starred
|
|
||||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
|
||||||
if (filterType === 'image') return isImage(f.mime_type)
|
|
||||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
|
||||||
if (filterType === 'collab') return !!f.note_id
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
try {
|
|
||||||
await onDelete(id)
|
|
||||||
toast.success(t('files.toast.trashed') || 'Moved to trash')
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) => {
|
|
||||||
try {
|
|
||||||
await filesApi.update(tripId, fileId, data)
|
|
||||||
refreshFiles()
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.assignError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
|
||||||
|
|
||||||
const openFile = (file) => {
|
|
||||||
if (isImage(file.mime_type)) {
|
|
||||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
|
||||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
|
||||||
} else {
|
|
||||||
setPreviewFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
|
||||||
const FileIcon = getFileIcon(file.mime_type)
|
|
||||||
const allLinkedPlaceIds = new Set<number>()
|
|
||||||
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
|
||||||
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
|
||||||
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
|
||||||
// All linked reservations (primary + file_links)
|
|
||||||
const allLinkedResIds = new Set<number>()
|
|
||||||
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)
|
|
||||||
return (
|
|
||||||
<div key={file.id} style={{
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
|
||||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
|
||||||
transition: 'border-color 0.12s',
|
|
||||||
opacity: isTrash ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
{/* Icon or thumbnail */}
|
|
||||||
<div
|
|
||||||
onClick={() => !isTrash && openFile(file)}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImage(file.mime_type)
|
|
||||||
? <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'
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
||||||
{file.uploaded_by_name && (
|
|
||||||
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
|
||||||
)}
|
|
||||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
|
||||||
<span
|
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{file.description && (
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
|
||||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
|
||||||
|
|
||||||
{linkedPlaces.map(p => (
|
|
||||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
|
||||||
))}
|
|
||||||
{linkedReservations.map(r => (
|
|
||||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
|
||||||
))}
|
|
||||||
{file.note_id && (
|
|
||||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions — always visible on mobile, hover on desktop */}
|
|
||||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
|
||||||
{isTrash ? (
|
|
||||||
<>
|
|
||||||
{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>}
|
|
||||||
{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 onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
|
||||||
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>
|
|
||||||
{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)} 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={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} 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)'}>
|
|
||||||
<Download size={14} />
|
|
||||||
</button>
|
|
||||||
{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>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function FileManager(props: FileManagerProps) {
|
||||||
|
const S = useFileManager(props)
|
||||||
|
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||||
|
|
||||||
{/* Assign modal */}
|
{/* Assign modal */}
|
||||||
{assignFileId && ReactDOM.createPortal(
|
{assignFileId && <AssignModal {...S} />}
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
onClick={() => setAssignFileId(null)}>
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
|
||||||
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 12px 0' }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
{t('files.noteLabel') || 'Note'}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('files.notePlaceholder')}
|
|
||||||
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
|
||||||
onBlur={e => {
|
|
||||||
const val = e.target.value.trim()
|
|
||||||
const file = files.find(f => f.id === assignFileId)
|
|
||||||
if (file && val !== (file.description || '')) {
|
|
||||||
handleAssign(file.id, { description: val } as any)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
|
||||||
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
|
||||||
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ overflowY: 'auto', padding: 8 }}>
|
|
||||||
{(() => {
|
|
||||||
const file = files.find(f => f.id === assignFileId)
|
|
||||||
if (!file) return null
|
|
||||||
const assignedPlaceIds = new Set<number>()
|
|
||||||
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
|
||||||
for (const day of days) {
|
|
||||||
const da = assignments[String(day.id)] || []
|
|
||||||
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
|
||||||
if (dayPlaces.length > 0) {
|
|
||||||
dayGroups.push({ day, dayPlaces })
|
|
||||||
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
|
||||||
const placeBtn = (p: Place) => {
|
|
||||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
|
||||||
return (
|
|
||||||
<button key={p.id} onClick={async () => {
|
|
||||||
if (isLinked) {
|
|
||||||
if (file.place_id === p.id) {
|
|
||||||
await handleAssign(file.id, { place_id: null })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
|
||||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
|
||||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!file.place_id) {
|
|
||||||
await handleAssign(file.id, { place_id: p.id })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} style={{
|
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
|
||||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
|
||||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const placesSection = places.length > 0 && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
{t('files.assignPlace')}
|
|
||||||
</div>
|
|
||||||
{dayGroups.map(({ day, dayPlaces }) => (
|
|
||||||
<div key={day.id}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
|
||||||
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
|
||||||
{(() => {
|
|
||||||
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
|
||||||
return badge ? (
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
|
||||||
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
|
||||||
}}>{badge}</span>
|
|
||||||
) : null
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{dayPlaces.map(placeBtn)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{unassigned.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
|
||||||
{unassigned.map(placeBtn)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const bookingsSection = reservations.length > 0 && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
{t('files.assignBooking')}
|
|
||||||
</div>
|
|
||||||
{reservations.map(r => {
|
|
||||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
|
||||||
return (
|
|
||||||
<button key={r.id} onClick={async () => {
|
|
||||||
if (isLinked) {
|
|
||||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
|
||||||
if (file.reservation_id === r.id) {
|
|
||||||
await handleAssign(file.id, { reservation_id: null })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
|
||||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
|
||||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Link: if no primary, set it; otherwise use file_links
|
|
||||||
if (!file.reservation_id) {
|
|
||||||
await handleAssign(file.id, { reservation_id: r.id })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} style={{
|
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
|
||||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
|
||||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasBoth = placesSection && bookingsSection
|
|
||||||
return (
|
|
||||||
<div className={hasBoth ? 'md:flex' : ''}>
|
|
||||||
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
|
||||||
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
|
||||||
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
|
||||||
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PDF preview modal */}
|
{/* PDF preview modal */}
|
||||||
{previewFile && ReactDOM.createPortal(
|
{previewFile && <PdfPreviewModal {...S} />}
|
||||||
<div
|
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
|
||||||
onClick={() => setPreviewFile(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
|
||||||
<span style={{ fontSize: 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 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
|
||||||
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')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
|
||||||
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)'}>
|
|
||||||
<Download size={13} /> {t('files.download') || 'Download'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setPreviewFile(null)}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<object
|
|
||||||
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)' }}>
|
|
||||||
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
|
||||||
</p>
|
|
||||||
</object>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
<FileManagerToolbar {...S} />
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
|
||||||
padding: '14px 16px 14px 22px',
|
|
||||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
|
||||||
}}>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
|
||||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{!showTrash && (
|
{showTrash ? <TrashView {...S} /> : <FilesView {...S} />}
|
||||||
<>
|
|
||||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
|
||||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
|
||||||
{[
|
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
|
||||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
|
||||||
{ id: 'pdf', label: t('files.filterPdf') },
|
|
||||||
{ id: 'image', label: t('files.filterImages') },
|
|
||||||
{ id: 'doc', label: t('files.filterDocs') },
|
|
||||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
|
||||||
].map(tab => {
|
|
||||||
const active = filterType === tab.id
|
|
||||||
const TabIcon = 'icon' in tab ? tab.icon : null
|
|
||||||
const count = tab.id === 'all' ? files.length
|
|
||||||
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
|
||||||
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
|
||||||
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
|
||||||
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
|
||||||
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
|
||||||
: 0
|
|
||||||
return (
|
|
||||||
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
|
||||||
style={{
|
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
|
||||||
background: active ? 'var(--bg-card)' : 'transparent',
|
|
||||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
fontWeight: active ? 500 : 400,
|
|
||||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
|
||||||
{'label' in tab && tab.label}
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10, fontWeight: 600,
|
|
||||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
|
||||||
color: 'var(--text-faint)',
|
|
||||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
|
||||||
}}>{count}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={toggleTrash} style={{
|
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
|
||||||
flexShrink: 0, marginLeft: 'auto',
|
|
||||||
opacity: showTrash ? 1 : 0.88,
|
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTrash ? (
|
|
||||||
/* Trash view */
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
|
||||||
{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',
|
|
||||||
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
{t('files.emptyTrash') || 'Empty Trash'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loadingTrash ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
|
|
||||||
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
|
||||||
</div>
|
|
||||||
) : trashFiles.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
|
||||||
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{trashFiles.map(file => renderFileRow(file, true))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Upload zone */}
|
|
||||||
{can('file_upload', trip) && <div
|
|
||||||
{...getRootProps()}
|
|
||||||
style={{
|
|
||||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
|
||||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
|
||||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
|
||||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
|
||||||
{uploading ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
||||||
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
|
||||||
{t('files.uploading')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
|
||||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
|
||||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{/* Filter tabs */}
|
|
||||||
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
|
||||||
{[
|
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
|
||||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
|
||||||
{ id: 'pdf', label: t('files.filterPdf') },
|
|
||||||
{ id: 'image', label: t('files.filterImages') },
|
|
||||||
{ id: 'doc', label: t('files.filterDocs') },
|
|
||||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
|
||||||
].map(tab => (
|
|
||||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
|
||||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
|
||||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
|
||||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
|
||||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
fontWeight: filterType === tab.id ? 600 : 400,
|
|
||||||
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
|
|
||||||
))}
|
|
||||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
|
||||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File list */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
|
||||||
{filteredFiles.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
|
||||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{filteredFiles.map(file => renderFileRow(file))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { X, MapPin, Ticket, Check } from 'lucide-react'
|
||||||
|
import { filesApi } from '../../api/client'
|
||||||
|
import type { Place, Reservation, Day } from '../../types'
|
||||||
|
import type { FileManagerState } from './useFileManager'
|
||||||
|
import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||||
|
import { transportIcon } from './FileManager.helpers'
|
||||||
|
|
||||||
|
export function AssignModal(S: FileManagerState) {
|
||||||
|
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setAssignFileId(null)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px 0' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.noteLabel') || 'Note'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('files.notePlaceholder')}
|
||||||
|
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
||||||
|
onBlur={e => {
|
||||||
|
const val = e.target.value.trim()
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (file && val !== (file.description || '')) {
|
||||||
|
handleAssign(file.id, { description: val } as any)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||||
|
{(() => {
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (!file) return null
|
||||||
|
const assignedPlaceIds = new Set<number>()
|
||||||
|
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
||||||
|
for (const day of days) {
|
||||||
|
const da = assignments[String(day.id)] || []
|
||||||
|
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
||||||
|
if (dayPlaces.length > 0) {
|
||||||
|
dayGroups.push({ day, dayPlaces })
|
||||||
|
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||||
|
const placeBtn = (p: Place, idx: number) => {
|
||||||
|
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||||
|
return (
|
||||||
|
<button key={`${p.id}-${idx}`} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
if (file.place_id === p.id) {
|
||||||
|
await handleAssign(file.id, { place_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.place_id) {
|
||||||
|
await handleAssign(file.id, { place_id: p.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const placesSection = places.length > 0 && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.assignPlace')}
|
||||||
|
</div>
|
||||||
|
{dayGroups.map(({ day, dayPlaces }) => (
|
||||||
|
<div key={day.id}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
|
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||||
|
{(() => {
|
||||||
|
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
||||||
|
return badge ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||||
|
}}>{badge}</span>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{dayPlaces.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{unassigned.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
||||||
|
{unassigned.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
|
||||||
|
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
|
||||||
|
|
||||||
|
const reservationBtn = (r: Reservation) => {
|
||||||
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
|
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||||
|
return (
|
||||||
|
<button key={r.id} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
if (file.reservation_id === r.id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingsSection = reservations.length > 0 && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{bookingReservations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.assignBooking')}
|
||||||
|
</div>
|
||||||
|
{bookingReservations.map(reservationBtn)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{transportReservations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
||||||
|
{t('files.assignTransport')}
|
||||||
|
</div>
|
||||||
|
{transportReservations.map(reservationBtn)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasBoth = placesSection && bookingsSection
|
||||||
|
return (
|
||||||
|
<div className={hasBoth ? 'md:flex' : ''}>
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
||||||
|
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
||||||
|
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
|
||||||
|
// Authenticated image — fetches a short-lived download token and renders the image
|
||||||
|
export 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
|
||||||
|
export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setHover(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
cursor: 'default',
|
||||||
|
}}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: name?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{hover && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Upload, FileText, Star } from 'lucide-react'
|
||||||
|
import type { FileManagerState } from './useFileManager'
|
||||||
|
import { FileRow } from './FileManagerRow'
|
||||||
|
|
||||||
|
export function FilesView(S: FileManagerState) {
|
||||||
|
const {
|
||||||
|
can, trip, getRootProps, getInputProps, isDragActive, uploading, t, allowedFileTypes,
|
||||||
|
files, filterType, setFilterType, filteredFiles,
|
||||||
|
} = S
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Upload zone */}
|
||||||
|
{can('file_upload', trip) && <div
|
||||||
|
{...getRootProps()}
|
||||||
|
style={{
|
||||||
|
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
|
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||||
|
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||||
|
{uploading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
{t('files.uploading')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||||
|
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||||
|
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||||
|
{ id: 'pdf', label: t('files.filterPdf') },
|
||||||
|
{ id: 'image', label: t('files.filterImages') },
|
||||||
|
{ id: 'doc', label: t('files.filterDocs') },
|
||||||
|
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||||
|
].map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||||
|
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||||
|
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
|
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||||
|
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
fontWeight: filterType === tab.id ? 600 : 400,
|
||||||
|
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||||
|
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
|
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filteredFiles.map(file => <FileRow key={file.id} {...S} file={file} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import type { TripFile } from '../../types'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||||
|
import { triggerDownload } from './FileManager.helpers'
|
||||||
|
|
||||||
|
// Image lightbox with gallery navigation
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
files: (TripFile & { url: string })[]
|
||||||
|
initialIndex: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [index, setIndex] = useState(initialIndex)
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
|
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||||
|
const file = files[index]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgSrc('')
|
||||||
|
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||||
|
}, [file?.url])
|
||||||
|
|
||||||
|
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||||
|
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
if (e.key === 'ArrowLeft') goPrev()
|
||||||
|
if (e.key === 'ArrowRight') goNext()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!file) return null
|
||||||
|
|
||||||
|
const hasPrev = index > 0
|
||||||
|
const hasNext = index < files.length - 1
|
||||||
|
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||||
|
<button onClick={e => { e.stopPropagation(); onClick() }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
||||||
|
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||||
|
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
||||||
|
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||||
|
onTouchEnd={e => {
|
||||||
|
if (touchStart === null) return
|
||||||
|
const diff = e.changedTouches[0].clientX - touchStart
|
||||||
|
if (diff > 60) goPrev()
|
||||||
|
else if (diff < -60) goNext()
|
||||||
|
setTouchStart(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||||
|
{file.original_name}
|
||||||
|
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
|
title={t('files.openTab')}>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
|
title={t('files.download') || 'Download'}>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main image + nav */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
{navBtn('left', goPrev, hasPrev)}
|
||||||
|
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||||
|
{navBtn('right', goNext, hasNext)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail strip */}
|
||||||
|
{files.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} style={{
|
||||||
|
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||||
|
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { ExternalLink, Download, X } from 'lucide-react'
|
||||||
|
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||||
|
import type { FileManagerState } from './useFileManager'
|
||||||
|
import { triggerDownload } from './FileManager.helpers'
|
||||||
|
|
||||||
|
export function PdfPreviewModal(S: FileManagerState) {
|
||||||
|
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setPreviewFile(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 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 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||||
|
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')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||||
|
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)'}>
|
||||||
|
<Download size={13} /> {t('files.download') || 'Download'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPreviewFile(null)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<object
|
||||||
|
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)' }}>
|
||||||
|
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Trash2, ExternalLink, Download, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil } from 'lucide-react'
|
||||||
|
import type { TripFile } from '../../types'
|
||||||
|
import type { FileManagerState } from './useFileManager'
|
||||||
|
import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||||
|
import { getFileIcon, isImage, formatSize, formatDateWithLocale, transportIcon, triggerDownload } from './FileManager.helpers'
|
||||||
|
import { AuthedImg } from './FileManagerAuthedImg'
|
||||||
|
import { AvatarChip } from './FileManagerAvatarChip'
|
||||||
|
import { SourceBadge } from './FileManagerSourceBadge'
|
||||||
|
|
||||||
|
export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolean }) {
|
||||||
|
const {
|
||||||
|
file, isTrash = false, places, reservations, t, locale, can, trip,
|
||||||
|
handleStar, handleRestore, handlePermanentDelete, handleDelete, openFile, setAssignFileId,
|
||||||
|
} = p
|
||||||
|
const FileIcon = getFileIcon(file.mime_type)
|
||||||
|
const allLinkedPlaceIds = new Set<number>()
|
||||||
|
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||||
|
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||||
|
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||||
|
// All linked reservations (primary + file_links)
|
||||||
|
const allLinkedResIds = new Set<number>()
|
||||||
|
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)
|
||||||
|
return (
|
||||||
|
<div key={file.id} style={{
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
|
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||||
|
transition: 'border-color 0.12s',
|
||||||
|
opacity: isTrash ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
{/* Icon or thumbnail */}
|
||||||
|
<div
|
||||||
|
onClick={() => !isTrash && openFile(file)}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage(file.mime_type)
|
||||||
|
? <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'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{file.uploaded_by_name && (
|
||||||
|
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
||||||
|
)}
|
||||||
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
|
<span
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file.description && (
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||||
|
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||||
|
|
||||||
|
{linkedPlaces.map(p => (
|
||||||
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
|
))}
|
||||||
|
{linkedReservations.map(r => (
|
||||||
|
TRANSPORT_TYPES.has(r.type)
|
||||||
|
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||||
|
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
|
))}
|
||||||
|
{file.note_id && (
|
||||||
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — always visible on mobile, hover on desktop */}
|
||||||
|
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
|
{isTrash ? (
|
||||||
|
<>
|
||||||
|
{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>}
|
||||||
|
{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 onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
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>
|
||||||
|
{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)} 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={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} 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)'}>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
{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>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user