mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +00:00
Compare commits
32 Commits
dev
..
070ef01328
| Author | SHA1 | Date | |
|---|---|---|---|
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 |
+2
-1
@@ -30,7 +30,8 @@ Thumbs.db
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -8,11 +8,11 @@ body:
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/liketrek/TREK/wiki
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/liketrek/TREK/discussions
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -13,8 +13,8 @@
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] 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)
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
|
||||
@@ -10,7 +10,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
@@ -77,7 +76,7 @@ jobs:
|
||||
body: [
|
||||
'## Wrong place for feature requests',
|
||||
'',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'',
|
||||
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||
].join('\n'),
|
||||
|
||||
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
@@ -98,7 +97,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/TREK,push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
@@ -145,16 +144,16 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/TREK@sha256:%s\n' *)
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/TREK:latest-pre" \
|
||||
-t "mauriceboe/TREK:$MAJOR_TAG" \
|
||||
-t "mauriceboe/TREK:$VERSION" \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/TREK:latest-pre
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
|
||||
|
||||
- name: Push git tag
|
||||
run: |
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
- 'plugin-sdk/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
@@ -33,7 +32,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
@@ -149,7 +147,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/TREK,push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
@@ -194,16 +192,16 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/TREK@sha256:%s\n' *)
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/TREK:latest" \
|
||||
-t "mauriceboe/TREK:$MAJOR_TAG" \
|
||||
-t "mauriceboe/TREK:$VERSION" \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/TREK:latest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Publish plugin-sdk to npm
|
||||
|
||||
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
|
||||
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
|
||||
# The package's prepublishOnly hook builds + tests before publishing.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'plugin-sdk-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -11,9 +11,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
# Docker Hub secrets are not exposed to pull requests from forks, so the
|
||||
# Scout login can never succeed there.
|
||||
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -37,5 +34,4 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
@@ -17,7 +17,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Contributor Covenant 3.0 Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
|
||||
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
|
||||
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
|
||||
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
|
||||
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
|
||||
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
|
||||
background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
|
||||
values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
|
||||
violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
|
||||
clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
|
||||
people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
|
||||
identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
|
||||
purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
|
||||
permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
|
||||
enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
|
||||
community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
|
||||
restricted behaviors.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
|
||||
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
|
||||
avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
|
||||
report@liketrek.com**.
|
||||
|
||||
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
|
||||
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
|
||||
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
|
||||
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
|
||||
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
|
||||
resolution.
|
||||
|
||||
## Addressing and Repairing Harm
|
||||
|
||||
****
|
||||
|
||||
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
|
||||
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
|
||||
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
|
||||
skipped.
|
||||
|
||||
1) Warning
|
||||
1) Event: A violation involving a single incident or series of incidents.
|
||||
2) Consequence: A private, written warning from the Community Moderators.
|
||||
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
|
||||
clarification on expectations.
|
||||
2) Temporarily Limited Activities
|
||||
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
|
||||
more serious violation.
|
||||
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
|
||||
seriousness of the situation and give the community members involved time to process the incident. The cooldown
|
||||
period may be limited to particular communication channels or interactions with particular community members.
|
||||
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
|
||||
impact, and being thoughtful about re-entering community spaces after the period is over.
|
||||
3) Temporary Suspension
|
||||
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
|
||||
single serious violation.
|
||||
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
|
||||
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
||||
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
|
||||
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
||||
4) Permanent Ban
|
||||
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
|
||||
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
|
||||
this person as a member.
|
||||
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
|
||||
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
|
||||
through other remedies has failed to change the behavior.
|
||||
3) Repair: There is no possible repair in cases of this severity.
|
||||
|
||||
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
|
||||
discretion and judgment, in keeping with the best interests of our community.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
|
||||
the community in public or other spaces. Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
|
||||
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
|
||||
of this license,
|
||||
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
For answers to common questions about Contributor Covenant, see the FAQ
|
||||
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
|
||||
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
|
||||
enforcement and community guideline resources can be found
|
||||
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
|
||||
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
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
|
||||
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/liketrek/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
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
|
||||
|
||||
## Development Environment
|
||||
|
||||
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
|
||||
+15
-31
@@ -1,10 +1,3 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -46,18 +39,27 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
||||
# kitinerary-extractor for booking-confirmation import:
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
fi && \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
@@ -66,23 +68,8 @@ ENV QT_QPA_PLATFORM=offscreen
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# The in-app help pages (/help) read this straight from disk at runtime, so the
|
||||
# docs always match the version running. Without it, wikiService falls back to
|
||||
# fetching the GitHub wiki, which tracks main and needs network access.
|
||||
COPY wiki ./wiki
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
@@ -103,8 +90,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Third-party data & attributions
|
||||
|
||||
TREK bundles and uses third-party data that requires attribution.
|
||||
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
||||
> https://doi.org/10.1371/journal.pone.0231866
|
||||
|
||||
geoBoundaries is licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
||||
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||
https://www.openstreetmap.org/copyright
|
||||
|
||||
## OurAirports — airport reference data
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<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>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/TREK?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -91,7 +90,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -109,7 +108,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -130,13 +128,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **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
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -158,9 +156,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **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
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -176,7 +173,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/TREK
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
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`).
|
||||
@@ -194,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -205,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -217,7 +214,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/TREK:latest
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
@@ -266,7 +263,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -280,7 +277,7 @@ helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
@@ -305,18 +302,15 @@ docker compose pull && docker compose up -d
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/TREK
|
||||
docker pull mauriceboe/trek
|
||||
docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/TREK
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -331,8 +325,6 @@ The script creates a timestamped DB backup before making changes and prompts for
|
||||
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
@@ -370,19 +362,6 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
|
||||
# so buffering must be off or tool results arrive late.
|
||||
location /mcp {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -418,15 +397,12 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
|
||||
| `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`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `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 |
|
||||
| `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 |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server 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` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
@@ -444,9 +420,8 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -462,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/TREK`).
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/liketrek/TREK</WebPage>
|
||||
<Forum>https://github.com/liketrek/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
+1
-3
@@ -39,9 +39,7 @@ See `values.yaml` for more options.
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.3.0
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.3.0"
|
||||
appVersion: "3.0.22"
|
||||
|
||||
@@ -21,9 +21,3 @@
|
||||
|
||||
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||
|
||||
5. Persistence:
|
||||
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
|
||||
The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
|
||||
when the pod restarts. Use only for testing.
|
||||
|
||||
@@ -13,9 +13,6 @@ data:
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_WIKI_DIR }}
|
||||
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
@@ -31,12 +28,6 @@ data:
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION }}
|
||||
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
@@ -73,9 +64,3 @@ data:
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_URL }}
|
||||
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
|
||||
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -6,12 +6,6 @@ metadata:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
replicas: 1
|
||||
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
|
||||
# RollingUpdate would start a second pod holding the same volume before the old one
|
||||
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
|
||||
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
|
||||
strategy:
|
||||
type: {{ .Values.updateStrategy | default "Recreate" }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "trek.name" . }}
|
||||
@@ -69,12 +63,6 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
- name: UNSPLASH_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
@@ -94,16 +82,8 @@ spec:
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
- name: uploads
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
|
||||
@@ -1,42 +1,26 @@
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.data.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.uploads.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.uploads.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
|
||||
@@ -17,9 +17,6 @@ data:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -47,7 +44,4 @@ stringData:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
+1
-38
@@ -1,14 +1,9 @@
|
||||
|
||||
image:
|
||||
repository: liketrek/TREK
|
||||
repository: mauriceboe/trek
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
|
||||
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
|
||||
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
|
||||
updateStrategy: Recreate
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
@@ -24,12 +19,6 @@ env:
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# TREK_WIKI_DIR: "/app/wiki"
|
||||
# Where the in-app Help pages (/help) read their content from. Leave unset: the
|
||||
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
|
||||
# the version you are running. Only set this to serve your own docs from a mounted
|
||||
# volume. If the path does not exist, Help falls back to fetching the public GitHub
|
||||
# wiki, which needs egress and tracks the latest release rather than your version.
|
||||
# DEFAULT_LANGUAGE: "en"
|
||||
# Default language on the login page for users with no saved preference.
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
@@ -45,10 +34,6 @@ env:
|
||||
# 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"
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# SESSION_DURATION: "24h"
|
||||
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||
# SESSION_DURATION_REMEMBER: "30d"
|
||||
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||
# TRUST_PROXY: "1"
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
@@ -78,12 +63,6 @@ env:
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||
# OVERPASS_TIMEOUT_MS: "12000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -103,12 +82,6 @@ secretEnv:
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
# Optional Unsplash Access Key for trip-cover and place-image search.
|
||||
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
|
||||
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
@@ -118,21 +91,11 @@ existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||
storageClassName: ""
|
||||
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
uploads:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
# Specify an existing PVC to bind. The other values are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// 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
|
||||
@@ -8,23 +7,18 @@ import { dismissSystemNotices } from './helpers'
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
|
||||
// don't hide a generic .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('.trek-modal-backdrop')
|
||||
// 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()
|
||||
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
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 })
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dismiss the release-notice modal (SystemNoticeHost), which greets a freshly seeded
|
||||
* user on first load and covers the dashboard — its backdrop swallows clicks aimed at
|
||||
* anything underneath, `.add-trip-card` included.
|
||||
*
|
||||
* The X only appears on the notice's last page, so page through first. Dismissal is
|
||||
* persisted server-side per user, but each spec gets a fresh DB, so every spec that
|
||||
* touches the dashboard has to clear it.
|
||||
*/
|
||||
export async function dismissSystemNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: /next/i })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled())) break
|
||||
await next.click()
|
||||
}
|
||||
|
||||
const dismiss = page.getByRole('button', { name: 'Dismiss' })
|
||||
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
|
||||
await dismiss.waitFor({ state: 'detached' }).catch(() => {})
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
|
||||
//
|
||||
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
|
||||
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
|
||||
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
|
||||
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
|
||||
// it — a jsdom unit test cannot express "coarse pointer at 834px".
|
||||
//
|
||||
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
|
||||
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
|
||||
// is WebKit underneath, which is why the reporter saw this in all three they tried.
|
||||
test.use({ ...devices['iPad Pro 11'] })
|
||||
|
||||
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
|
||||
await expect(createBtn).toBeVisible()
|
||||
const title = `iPad 1432 ${Date.now()}`
|
||||
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await createBtn.click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
|
||||
// Seed enough places for the list to overflow and actually need scrolling.
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
|
||||
})
|
||||
expect(res.ok(), `seed place ${i}`).toBeTruthy()
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
// width. If either is wrong, everything below proves nothing.
|
||||
const env = await page.evaluate(() => ({
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
width: window.innerWidth,
|
||||
}))
|
||||
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
|
||||
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
|
||||
|
||||
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
|
||||
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
|
||||
await expect(row).toHaveAttribute('draggable', 'false')
|
||||
|
||||
// 2. The list must scroll, and no drop-to-import overlay may appear.
|
||||
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
|
||||
const before = await scroller.evaluate(el => el.scrollTop)
|
||||
const box = (await scroller.boundingBox())!
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
|
||||
await scroller.evaluate(el => el.scrollBy(0, 200))
|
||||
const after = await scroller.evaluate(el => el.scrollTop)
|
||||
expect(after, 'places list scrolled').toBeGreaterThan(before)
|
||||
await expect(page.getByText('Drop to import')).toHaveCount(0)
|
||||
|
||||
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
|
||||
// they must be visible (they were opacity:0 above 767px).
|
||||
const arrowOpacity = await page.evaluate(() => {
|
||||
const el = document.querySelector('.reorder-buttons')
|
||||
return el ? getComputedStyle(el).opacity : 'absent'
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
|
||||
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
|
||||
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
|
||||
// at opacity:0 with no way to reveal them.
|
||||
//
|
||||
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
|
||||
// note carried an invisible, fully clickable target that silently reordered the trip.
|
||||
// These cases pin both halves — hidden means non-interactive, hover means visible.
|
||||
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
const title = `Reorder ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
|
||||
const dayId = (daysRes.days ?? daysRes)[0].id
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name, lat: 48.85, lng: 2.35 },
|
||||
})
|
||||
const body = await res.json()
|
||||
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
|
||||
data: { place_id: body.place?.id ?? body.id },
|
||||
})
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
|
||||
await expect(row).toBeVisible({ timeout: 20_000 })
|
||||
const arrows = row.locator('.reorder-buttons')
|
||||
|
||||
// Unhovered: invisible AND inert — a click there must not land on the button.
|
||||
const idle = await arrows.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
|
||||
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
|
||||
})
|
||||
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
|
||||
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
|
||||
|
||||
// Hovered: revealed and clickable.
|
||||
await row.hover()
|
||||
await expect(arrows).toHaveCSS('opacity', '1')
|
||||
await expect(arrows).toHaveCSS('pointer-events', 'auto')
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test, expect } from './shot'
|
||||
|
||||
/**
|
||||
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
|
||||
* this project otherwise inherits, so these render as a logged-out visitor sees
|
||||
* them — which is the entire point of the login and registration pages.
|
||||
*/
|
||||
test.use({ storageState: undefined })
|
||||
|
||||
test('login page', async ({ page, shot }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible()
|
||||
await shot.page_('Login')
|
||||
})
|
||||
|
||||
test('registration page', async ({ page, shot }) => {
|
||||
await page.goto('/register')
|
||||
await page.waitForTimeout(500)
|
||||
await shot.page_('Registration')
|
||||
})
|
||||
|
||||
test('forgot password', async ({ page, shot }) => {
|
||||
await page.goto('/forgot-password')
|
||||
await page.waitForTimeout(500)
|
||||
await shot.page_('PasswordReset')
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Collab surfaces, one capture each.
|
||||
*
|
||||
* Until now a single Collab.png illustrated four different wiki pages — chat,
|
||||
* notes, polls and the What's Next widget — so at most one of them showed the
|
||||
* feature its page described.
|
||||
*
|
||||
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
|
||||
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
|
||||
* So each capture targets its own card element rather than clicking a tab.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The panel card containing a given piece of seeded content — see cardClass in
|
||||
* CollabPanel.tsx:20.
|
||||
*
|
||||
* Matching on content rather than the panel heading is deliberate: the headings
|
||||
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
|
||||
* those same words also appear in the mobile tab bar, so a heading match is both
|
||||
* wrong-cased and ambiguous.
|
||||
*/
|
||||
function card(page: Page, contains: string): Locator {
|
||||
return page
|
||||
.locator('div.bg-surface-card.rounded-2xl')
|
||||
.filter({ hasText: contains })
|
||||
.last()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
|
||||
await page.waitForTimeout(1200)
|
||||
})
|
||||
|
||||
test('collab chat', async ({ page, shot }) => {
|
||||
// Seeded as three different people; a single-voice log would misrepresent it.
|
||||
// The chat auto-scrolls to the newest message, so assert on the last line of
|
||||
// the seeded conversation rather than the first — the first is off-screen.
|
||||
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
|
||||
await shot.element('CollabChat', card(page, 'kaiseki'))
|
||||
})
|
||||
|
||||
test('collab notes', async ({ page, shot }) => {
|
||||
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
|
||||
await shot.element('CollabNotes', card(page, 'Rail passes'))
|
||||
})
|
||||
|
||||
test('collab polls', async ({ page, shot }) => {
|
||||
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
|
||||
await shot.element('CollabPolls', card(page, 'free for Nara'))
|
||||
})
|
||||
|
||||
test("what's next widget", async ({ page, shot }) => {
|
||||
await shot.element('WhatsNext', card(page, "What's Next"))
|
||||
})
|
||||
|
||||
test('collab overview', async ({ page, shot }) => {
|
||||
await shot.page_('Collab')
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Detail pages and the surfaces that need a couple of clicks to reach.
|
||||
*
|
||||
* Each capture asserts something specific to the surface before shooting, so a
|
||||
* navigation that quietly lands on a fallback (or an addon that is off) fails
|
||||
* the run instead of producing a screenshot of the wrong screen.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test('collection detail', async ({ page, shot }) => {
|
||||
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
|
||||
await page.goto(`/collections/${seed.collectionId}`)
|
||||
await clearNotices(page)
|
||||
await shot.page_('CollectionDetail')
|
||||
})
|
||||
|
||||
test('journey detail', async ({ page, shot }) => {
|
||||
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
|
||||
await page.goto(`/journey/${seed.journeyId}`)
|
||||
await clearNotices(page)
|
||||
await shot.page_('JourneyDetail')
|
||||
})
|
||||
|
||||
test('mcp access — admin', async ({ page, shot }) => {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
await shot.page_('MCPAccess')
|
||||
})
|
||||
|
||||
test('two-factor setup', async ({ page, shot }) => {
|
||||
await page.goto('/settings')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
// The enrolment flow is behind a button whose label varies with state; match
|
||||
// loosely and fall back to capturing the tab itself.
|
||||
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
|
||||
if (await enable.isVisible().catch(() => false)) {
|
||||
await enable.click()
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
await shot.page_('2FA')
|
||||
})
|
||||
|
||||
/**
|
||||
* Settle-up.
|
||||
*
|
||||
* WARNING for anyone extending this file: the "Settle up" button in the Costs
|
||||
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
|
||||
* of this test clicked it, which zeroed every balance and left the capture
|
||||
* showing "Everyone's square". Because all screenshot specs share one database
|
||||
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
|
||||
* same run.
|
||||
*
|
||||
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
|
||||
* instead — same surface, no side effect — and close it again.
|
||||
*/
|
||||
test('costs — record a settle-up payment', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
|
||||
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
|
||||
await addPayment.click()
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
const modal = page.locator('.trek-modal-backdrop > div').first()
|
||||
await expect(modal).toBeVisible()
|
||||
await shot.element('CostsSettleUp', modal)
|
||||
})
|
||||
|
||||
test('trip files', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}/files`)
|
||||
await clearNotices(page)
|
||||
await expect(page).toHaveURL(/files/)
|
||||
await shot.page_('Documents')
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Modals and dialogs.
|
||||
*
|
||||
* Captured as element screenshots (not full page) so the wiki gets the dialog
|
||||
* itself rather than a dimmed backdrop with a small box in the middle. Each one
|
||||
* asserts the dialog is actually open first — a missed click would otherwise
|
||||
* silently produce a screenshot of the page behind it.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
|
||||
* role="dialog" nor aria-modal, so there is no accessible role to query — the
|
||||
* backdrop class is the only stable hook. Target its child, which is the panel
|
||||
* itself, so the capture excludes the dimmed backdrop.
|
||||
*/
|
||||
function dialog(page: import('@playwright/test').Page) {
|
||||
return page.locator('.trek-modal-backdrop > div').first()
|
||||
}
|
||||
|
||||
test('create trip modal — with the new currency field', async ({ page, shot }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: /new trip/i }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('TripCreate', dialog(page))
|
||||
})
|
||||
|
||||
test('share dialog', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: /share/i }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('Share', dialog(page))
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Top-level navigable surfaces. One capture per route; anything that needs a
|
||||
* dialog opened or a tab clicked lives in its own spec so a failure there
|
||||
* cannot take these down with it.
|
||||
*
|
||||
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
|
||||
* for which wiki page consumes which file.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
test('dashboard', async ({ page, shot }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await shot.page_('DashboardWidgets')
|
||||
})
|
||||
|
||||
test('trip planner', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await shot.page_('TripPlanner')
|
||||
})
|
||||
|
||||
test('atlas', async ({ page, shot }) => {
|
||||
await page.goto('/atlas')
|
||||
await shot.page_('Atlas')
|
||||
})
|
||||
|
||||
test('vacay', async ({ page, shot }) => {
|
||||
await page.goto('/vacay')
|
||||
await shot.page_('Vacay')
|
||||
})
|
||||
|
||||
test('collections', async ({ page, shot }) => {
|
||||
await page.goto('/collections')
|
||||
await shot.page_('Collections')
|
||||
})
|
||||
|
||||
test('journey', async ({ page, shot }) => {
|
||||
await page.goto('/journey')
|
||||
await shot.page_('Journey')
|
||||
})
|
||||
|
||||
test('notifications inbox', async ({ page, shot }) => {
|
||||
await page.goto('/notifications')
|
||||
await shot.page_('NotificationsInbox')
|
||||
})
|
||||
|
||||
test('in-app help', async ({ page, shot }) => {
|
||||
await page.goto('/help')
|
||||
await shot.page_('HelpInApp')
|
||||
})
|
||||
|
||||
test('files', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}/files`)
|
||||
await shot.page_('Files')
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Trip-planner tabs and dialogs.
|
||||
*
|
||||
* Tabs are reached by their visible label rather than a test id, deliberately:
|
||||
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
|
||||
* instead of silently capturing the wrong panel — which is exactly how the
|
||||
* current wiki ended up with screenshots the text contradicts.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
async function openTab(page: import('@playwright/test').Page, label: string) {
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test('costs panel', async ({ page, shot }) => {
|
||||
await openTab(page, 'Costs')
|
||||
await shot.page_('Costs')
|
||||
})
|
||||
|
||||
test('lists — packing', async ({ page, shot }) => {
|
||||
await openTab(page, 'Lists')
|
||||
await shot.page_('PackingList')
|
||||
})
|
||||
|
||||
test('transports', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await shot.page_('Transports')
|
||||
})
|
||||
|
||||
test('bookings', async ({ page, shot }) => {
|
||||
await openTab(page, 'Book')
|
||||
await shot.page_('Bookings')
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Moves captured screenshots from the staging directory into wiki/assets/,
|
||||
// downscaling and re-encoding on the way.
|
||||
//
|
||||
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
|
||||
// of raw pixels. The wiki renders images at roughly 800–1000px, so shipping
|
||||
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
|
||||
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
|
||||
// on HiDPI displays at the size it is actually shown.
|
||||
//
|
||||
// Usage: node e2e/screenshots/promote.mjs [--dry]
|
||||
import sharp from 'sharp'
|
||||
import { readdirSync, mkdirSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
|
||||
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
|
||||
const MAX_WIDTH = 1600
|
||||
const dry = process.argv.includes('--dry')
|
||||
|
||||
mkdirSync(DEST, { recursive: true })
|
||||
|
||||
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
|
||||
if (!files.length) {
|
||||
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let before = 0
|
||||
let after = 0
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const src = path.join(SRC, file)
|
||||
const dest = path.join(DEST, file)
|
||||
const srcBytes = statSync(src).size
|
||||
before += srcBytes
|
||||
|
||||
const img = sharp(src)
|
||||
const { width } = await img.metadata()
|
||||
|
||||
const pipeline = sharp(src)
|
||||
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
|
||||
.png({ compressionLevel: 9, effort: 10 })
|
||||
|
||||
const buf = await pipeline.toBuffer()
|
||||
after += buf.length
|
||||
|
||||
const pct = Math.round((1 - buf.length / srcBytes) * 100)
|
||||
console.log(
|
||||
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)} → ${kb(buf.length).padStart(8)} (-${pct}%)`,
|
||||
)
|
||||
if (!dry) await sharp(buf).toFile(dest)
|
||||
}
|
||||
|
||||
console.log(`\n${files.length} files: ${kb(before)} → ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
|
||||
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
|
||||
|
||||
function kb(bytes) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { seedDemoData } from './seed'
|
||||
|
||||
/**
|
||||
* Populates the throwaway E2E database with the demo trip before any screenshot
|
||||
* runs. Its own Playwright project so it executes exactly once, after `setup`
|
||||
* (which produces the authenticated storageState) and before `screenshots`.
|
||||
*
|
||||
* The resulting ids are written to disk because Playwright projects do not
|
||||
* share memory — the capture specs read them back.
|
||||
*/
|
||||
setup('seed the demo trip', async ({ page, playwright }) => {
|
||||
// page.request carries the storageState cookie, so this is authenticated as
|
||||
// the admin. The factory hands the seeder throwaway contexts for the other
|
||||
// members — see the comment in seed.ts on why they must not share one.
|
||||
const result = await seedDemoData(page.request, token =>
|
||||
playwright.request.newContext({
|
||||
baseURL: 'http://localhost:5173',
|
||||
// MUST be explicit: newContext otherwise picks up the project's
|
||||
// storageState, i.e. the admin's trek_session cookie — and the server
|
||||
// reads the cookie BEFORE the Authorization header
|
||||
// (server/src/middleware/auth.ts:9), so every "member" write would be
|
||||
// recorded as the admin while still returning 200.
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.tripId, 'trip was created').toBeTruthy()
|
||||
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
|
||||
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
|
||||
})
|
||||
@@ -1,347 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Demo data for the documentation screenshots.
|
||||
*
|
||||
* Seeded over the REST API (not the DB) so it exercises the same paths a real
|
||||
* user would and stays honest about validation. The session cookie comes from
|
||||
* the storageState that auth.setup.ts writes, so `page.request` is already
|
||||
* authenticated as the seeded admin.
|
||||
*
|
||||
* Design notes that matter for the screenshots:
|
||||
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
|
||||
* currency rework (per-trip currency, frozen FX rates, foreign-currency
|
||||
* settle-up) — the reader would see nothing new.
|
||||
* - Two extra members exist so splits, avatars and sharing tiers render with
|
||||
* real names instead of a lonely single-user state.
|
||||
* - Dates sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
|
||||
* have something to show.
|
||||
*/
|
||||
|
||||
const TRIP = {
|
||||
title: 'Autumn in Japan',
|
||||
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
|
||||
start_date: '2026-09-12',
|
||||
end_date: '2026-09-21',
|
||||
currency: 'JPY',
|
||||
reminder_days: 3,
|
||||
}
|
||||
|
||||
const MEMBERS = [
|
||||
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
|
||||
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
|
||||
]
|
||||
|
||||
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
|
||||
const PLACES = [
|
||||
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
|
||||
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
|
||||
notes: 'Go before 08:00 — the gate is empty and the light is better.',
|
||||
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
|
||||
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
|
||||
description: 'Immersive digital art museum you walk through barefoot.',
|
||||
notes: 'Timed entry — book at least a week ahead.',
|
||||
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
|
||||
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
|
||||
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
|
||||
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
|
||||
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
|
||||
description: 'Forest shrine in the middle of the city.',
|
||||
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
|
||||
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
|
||||
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
|
||||
notes: 'The crowds thin out after the first 20 minutes of climbing.',
|
||||
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
|
||||
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
|
||||
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
|
||||
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
|
||||
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
|
||||
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
|
||||
notes: 'Come hungry. Try the tamagoyaki.',
|
||||
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
|
||||
]
|
||||
|
||||
const EXPENSES = [
|
||||
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
|
||||
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
|
||||
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
|
||||
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
|
||||
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
|
||||
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
|
||||
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
|
||||
expense_date: '2026-09-13' },
|
||||
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
|
||||
expense_date: '2026-09-17' },
|
||||
]
|
||||
|
||||
const PACKING = [
|
||||
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
|
||||
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
|
||||
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
|
||||
]
|
||||
|
||||
const TODOS = [
|
||||
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
|
||||
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
|
||||
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
|
||||
]
|
||||
|
||||
export interface SeedResult {
|
||||
tripId: number
|
||||
memberIds: number[]
|
||||
dayIds: number[]
|
||||
placeIds: number[]
|
||||
collectionId?: number
|
||||
journeyId?: number
|
||||
}
|
||||
|
||||
/** Throws with the response body on failure — a silent 4xx here would produce
|
||||
* a screenshot of an empty screen, which is worse than a loud crash. */
|
||||
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
|
||||
path: string, body?: unknown): Promise<T> {
|
||||
const res = await api[method](path, body === undefined ? {} : { data: body })
|
||||
if (!res.ok()) {
|
||||
throw new Error(`${method.toUpperCase()} ${path} → ${res.status()}\n${await res.text()}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
|
||||
|
||||
export async function seedDemoData(
|
||||
api: APIRequestContext,
|
||||
newContext?: ContextFactory,
|
||||
): Promise<SeedResult> {
|
||||
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
|
||||
// every later call to those modules 403s until these are flipped.
|
||||
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
|
||||
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
|
||||
}
|
||||
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
|
||||
|
||||
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
|
||||
// when a default does. They match the current defaults (ba3733da made
|
||||
// celsius/metric/24h consistent across the store and the settings UI) —
|
||||
// stating them here keeps the captures reproducible either way.
|
||||
await call(api, 'post', '/api/settings/bulk', {
|
||||
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
|
||||
})
|
||||
|
||||
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
|
||||
const memberIds: number[] = []
|
||||
for (const m of MEMBERS) {
|
||||
const res = await api.post('/api/admin/users', { data: m })
|
||||
if (res.ok()) {
|
||||
const { user } = (await res.json()) as { user: { id: number } }
|
||||
memberIds.push(user.id)
|
||||
} else if (res.status() !== 409) {
|
||||
throw new Error(`create user ${m.username} → ${res.status()}\n${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The trip, in JPY.
|
||||
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
|
||||
const tripId = trip.id
|
||||
|
||||
for (const m of MEMBERS) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
|
||||
}
|
||||
|
||||
// 4. Days are auto-generated by trip creation — read them back for assignment.
|
||||
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
|
||||
api, 'get', `/api/trips/${tripId}/days`)
|
||||
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
|
||||
|
||||
// 5. Places, then pin each onto its day.
|
||||
const placeIds: number[] = []
|
||||
for (const p of PLACES) {
|
||||
const { day, ...payload } = p
|
||||
const { place } = await call<{ place: { id: number } }>(
|
||||
api, 'post', `/api/trips/${tripId}/places`, payload)
|
||||
placeIds.push(place.id)
|
||||
const dayId = dayIds[day]
|
||||
if (dayId) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
|
||||
{ place_id: place.id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. A day note, so the itinerary shows more than places.
|
||||
if (dayIds[0]) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
|
||||
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
|
||||
time: '08:15', icon: 'train',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 7. Costs. Split across everyone so the settle-up view has real balances.
|
||||
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
|
||||
// and a hand-supplied one fights the settlement maths.
|
||||
const allMembers = [1, ...memberIds]
|
||||
for (const e of EXPENSES) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/budget`, {
|
||||
...e,
|
||||
payers: [{ user_id: 1, amount: e.total_price }],
|
||||
member_ids: allMembers,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
|
||||
if (memberIds[0]) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
|
||||
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 8. Packing — category is free text on the item, there is no category resource.
|
||||
for (const group of PACKING) {
|
||||
for (const name of group.items) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/packing`, {
|
||||
name, category: group.category, visibility: 'common',
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of TODOS) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
|
||||
}
|
||||
|
||||
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
|
||||
// are silently dropped by the server, leaving a booking with no route.
|
||||
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
|
||||
title: 'LH716 FRA → HND',
|
||||
type: 'flight',
|
||||
reservation_time: '2026-09-12T13:05:00',
|
||||
reservation_end_time: '2026-09-13T08:25:00',
|
||||
confirmation_number: 'X7K2QP',
|
||||
status: 'confirmed',
|
||||
location: 'Frankfurt Airport',
|
||||
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
|
||||
departure_airport: 'FRA', arrival_airport: 'HND' },
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
|
||||
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
|
||||
local_date: '2026-09-12', local_time: '13:05' },
|
||||
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
|
||||
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
|
||||
local_date: '2026-09-13', local_time: '08:25' },
|
||||
],
|
||||
}).catch(() => {})
|
||||
|
||||
// 10. A collection, populated from the trip's own places.
|
||||
let collectionId: number | undefined
|
||||
try {
|
||||
const created = await call<{ id: number } | { collection: { id: number } }>(
|
||||
api, 'post', '/api/addons/collections',
|
||||
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
|
||||
color: '#ef4444', icon: 'MapPin' })
|
||||
collectionId = 'id' in created ? created.id : created.collection.id
|
||||
for (const placeId of placeIds.slice(4)) {
|
||||
await call(api, 'post', '/api/addons/collections/places/from-trip', {
|
||||
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
|
||||
|
||||
// 11. Journey. Entries are generated server-side from the trip, then filled in.
|
||||
let journeyId: number | undefined
|
||||
try {
|
||||
const j = await call<{ id: number } | { journey: { id: number } }>(
|
||||
api, 'post', '/api/journeys',
|
||||
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
|
||||
journeyId = 'id' in j ? j.id : j.journey.id
|
||||
} catch { /* journey addon unavailable */ }
|
||||
|
||||
// 11b. Collab: chat, notes and polls.
|
||||
//
|
||||
// Chat is only convincing with more than one voice, and every collab
|
||||
// write is attributed to the acting user — so messages and votes are
|
||||
// posted as the members themselves, via their own bearer tokens, not as
|
||||
// the admin. A single-speaker chat log would misrepresent the feature.
|
||||
// Each member gets its OWN request context. Logging in through the shared
|
||||
// one would set the trek_session cookie on it, and extractToken()
|
||||
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
|
||||
// Authorization header — so every later write, including the admin's,
|
||||
// would silently be attributed to whoever logged in last.
|
||||
const members: Record<string, APIRequestContext> = {}
|
||||
for (const m of MEMBERS) {
|
||||
if (!newContext) break
|
||||
const anon = await newContext()
|
||||
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
|
||||
if (!res.ok()) { await anon.dispose(); continue }
|
||||
const { token } = (await res.json()) as { token?: string }
|
||||
await anon.dispose()
|
||||
if (token) members[m.username] = await newContext(token)
|
||||
}
|
||||
/** The member's own context, or the admin's as a visible fallback. */
|
||||
const as = (username: string): APIRequestContext => members[username] ?? api
|
||||
|
||||
const collab = `/api/trips/${tripId}/collab`
|
||||
|
||||
for (const n of [
|
||||
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
|
||||
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
|
||||
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
|
||||
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
|
||||
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
|
||||
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
|
||||
]) {
|
||||
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
|
||||
}
|
||||
|
||||
const pollRes = await api.post(`${collab}/polls`, {
|
||||
data: {
|
||||
question: 'Which day should we keep free for Nara?',
|
||||
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
|
||||
multiple: false,
|
||||
},
|
||||
})
|
||||
if (pollRes.ok()) {
|
||||
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
|
||||
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
|
||||
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
|
||||
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
|
||||
}
|
||||
await api.post(`${collab}/polls`, {
|
||||
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
|
||||
}).catch(() => {})
|
||||
|
||||
const conversation: Array<[string, string]> = [
|
||||
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
|
||||
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
|
||||
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
|
||||
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
|
||||
['mira', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
|
||||
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
|
||||
['admin', "It's included — kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
|
||||
]
|
||||
for (const [who, text] of conversation) {
|
||||
const ctx = who === 'admin' ? api : as(who)
|
||||
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
|
||||
}
|
||||
|
||||
for (const ctx of Object.values(members)) await ctx.dispose()
|
||||
|
||||
// 12. Plugins, installed from the community registry.
|
||||
//
|
||||
// Registry install is the ONLY path that produces a representative
|
||||
// screenshot. Dev-link and sideload both stamp the plugin card with a
|
||||
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
|
||||
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
|
||||
// "Link a local plugin" row in the panel. Documenting either would show
|
||||
// readers a UI they will never have.
|
||||
//
|
||||
// Needs network. If the registry is unreachable the plugin screenshots are
|
||||
// skipped loudly rather than silently captured in a misleading state.
|
||||
for (const id of ['koffi', 'trip-doctor']) {
|
||||
const res = await api.post('/api/admin/plugins/install', { data: { id } })
|
||||
if (!res.ok()) {
|
||||
console.log(`PLUGIN INSTALL FAILED ${id} → ${res.status()} ${await res.text()}`)
|
||||
continue
|
||||
}
|
||||
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
|
||||
}
|
||||
|
||||
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Settings and Admin tabs.
|
||||
*
|
||||
* Both pages use the shared PageSidebar with client-side tab state (no URL
|
||||
* segment per tab), so each capture clicks its way in. Labels come from
|
||||
* shared/src/i18n/en — note "General" is the tab the wiki still calls
|
||||
* "Display", which is one of the corrections this screenshot run supports.
|
||||
*/
|
||||
|
||||
async function openSidebarTab(page: Page, label: string) {
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
test.describe('user settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
// Filename kept as UsrSettings.png — the wiki already references it.
|
||||
test('general tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'General')
|
||||
await shot.page_('UsrSettings')
|
||||
})
|
||||
|
||||
test('appearance tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Appearance')
|
||||
await shot.page_('UsrSettingsAppearance')
|
||||
})
|
||||
|
||||
test('map tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Map')
|
||||
await shot.page_('UsrSettingsMap')
|
||||
})
|
||||
|
||||
test('notifications tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Notifications')
|
||||
await shot.page_('NotifSettings')
|
||||
})
|
||||
|
||||
test('offline tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Offline')
|
||||
await shot.page_('SettingsOffline')
|
||||
})
|
||||
|
||||
test('account tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Account')
|
||||
await shot.page_('SettingsAccount')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('admin panel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
test('users', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Users')
|
||||
await shot.page_('UsersAndInvites')
|
||||
})
|
||||
|
||||
test('user defaults', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'User Defaults')
|
||||
await shot.page_('AdminUserDefaults')
|
||||
})
|
||||
|
||||
test('personalization', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Personalization')
|
||||
await shot.page_('CategoryManager')
|
||||
})
|
||||
|
||||
test('addons', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Addons')
|
||||
await shot.page_('Addons-Overview')
|
||||
})
|
||||
|
||||
test('plugins', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Plugins')
|
||||
await shot.page_('AdminPlugins')
|
||||
})
|
||||
|
||||
test('github releases', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'GitHub')
|
||||
await shot.page_('GithubReleases')
|
||||
})
|
||||
|
||||
test('backup', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Backup')
|
||||
await shot.page_('Backup')
|
||||
})
|
||||
|
||||
test('audit log', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Audit')
|
||||
await shot.page_('Audit')
|
||||
})
|
||||
|
||||
test('admin panel overview', async ({ page, shot }) => {
|
||||
await shot.page_('AdminPanel')
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Shared plumbing for the documentation screenshot run (`npm run shots`).
|
||||
*
|
||||
* These are not assertions about behaviour — they drive the app to a known
|
||||
* state and capture it for the wiki. They live behind their own Playwright
|
||||
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
|
||||
* never pays for them.
|
||||
*
|
||||
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
|
||||
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
|
||||
*/
|
||||
|
||||
// Playwright runs from the client workspace root, matching how
|
||||
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
|
||||
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
|
||||
|
||||
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
|
||||
export const VIEWPORT = { width: 1440, height: 900 }
|
||||
|
||||
export const test = base.extend<{ shot: Shot }>({
|
||||
// Overriding `page` (rather than doing this inside the `shot` fixture) is
|
||||
// deliberate: fixtures initialise lazily, so a route registered in `shot`
|
||||
// lands AFTER any beforeEach hook has already navigated — too late to
|
||||
// intercept the config request.
|
||||
page: async ({ page }, use) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await hideDevOnlyUi(page)
|
||||
await use(page)
|
||||
},
|
||||
shot: async ({ page }, use) => {
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
await use(new Shot(page))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
|
||||
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
|
||||
* "Dev: Notifications" tab that no real deployment ever shows.
|
||||
*
|
||||
* Rewriting the response is the surgical fix. Flipping the server to
|
||||
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
|
||||
* HSTS header on localhost would upgrade the run to https and break it.
|
||||
*/
|
||||
async function hideDevOnlyUi(page: Page): Promise<void> {
|
||||
await page.route('**/api/auth/app-config', async route => {
|
||||
const res = await route.fetch()
|
||||
const body = await res.json()
|
||||
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
|
||||
})
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
export class Shot {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Capture the full viewport. `name` is the target filename in wiki/assets/
|
||||
* (without extension) so the mapping from screenshot to doc page is literal.
|
||||
*/
|
||||
async page_(name: string): Promise<void> {
|
||||
await this.settle()
|
||||
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
/** Capture one element — preferred for dialogs, panels and cards. */
|
||||
async element(name: string, target: Locator): Promise<void> {
|
||||
await this.settle()
|
||||
await expect(target).toBeVisible()
|
||||
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet the page before capturing: fonts loaded, images decoded, animations
|
||||
* finished, no pending network. Without this, screenshots catch skeleton
|
||||
* loaders and half-faded modals, which is exactly how the current wiki
|
||||
* assets ended up inconsistent.
|
||||
*/
|
||||
private async settle(): Promise<void> {
|
||||
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
|
||||
// fully idle and an unbounded wait would burn the whole test timeout.
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
|
||||
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
|
||||
await this.page.evaluate(async () => { await document.fonts.ready })
|
||||
await this.page.evaluate(async () => {
|
||||
await Promise.all(
|
||||
Array.from(document.images)
|
||||
.filter(img => !img.complete)
|
||||
.map(img => new Promise(res => { img.onload = img.onerror = res })),
|
||||
)
|
||||
})
|
||||
// Let CSS transitions land (modal fade-in, sidebar slide).
|
||||
await this.page.waitForTimeout(400)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
|
||||
* but tolerant: on a seeded DB the notice may already be cleared.
|
||||
*/
|
||||
export async function clearNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: /next/i })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled().catch(() => false))) break
|
||||
await next.click().catch(() => {})
|
||||
}
|
||||
for (const label of ['Dismiss', 'OK']) {
|
||||
const btn = page.getByRole('button', { name: label, exact: true })
|
||||
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
|
||||
await btn.click().catch(() => {})
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// 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
|
||||
@@ -7,17 +6,12 @@ import { dismissSystemNotices } from './helpers'
|
||||
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// Create a trip to open.
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Planner ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
// Open it from the dashboard.
|
||||
|
||||
+6
-5
@@ -5,10 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
|
||||
before first paint AND complies with the prod CSP (script-src 'self'). -->
|
||||
<script src="/theme-boot.js"></script>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -17,12 +13,17 @@
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.3.0",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,11 +17,7 @@
|
||||
"lint": "eslint .",
|
||||
"lint:check": "eslint .",
|
||||
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||
"theme:lint": "node scripts/theme-lint.mjs",
|
||||
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
|
||||
"e2e": "playwright test",
|
||||
"shots": "playwright test --project=screenshots",
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
@@ -34,15 +30,11 @@
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.4.2",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
@@ -55,7 +47,6 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
@@ -67,12 +58,11 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -90,8 +80,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,28 +35,6 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
|
||||
// run by its own testMatch — these capture artwork for wiki/assets/, they
|
||||
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
|
||||
// Populates the demo trip the screenshots are taken of. Separate project so
|
||||
// it runs exactly once, between auth and capture.
|
||||
{
|
||||
name: 'seed',
|
||||
testMatch: /seed\.setup\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'screenshots',
|
||||
testMatch: /\.shot\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.tmp/state.json',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
dependencies: ['seed'],
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
|
||||
*
|
||||
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
|
||||
* so it runs before first paint AND complies with the production CSP
|
||||
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
|
||||
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
|
||||
* this in sync with that module's snapshot shape + apply logic.
|
||||
*
|
||||
* It must never throw — any failure silently falls back to the default look.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('trek_appearance');
|
||||
if (!raw) return;
|
||||
var s = JSON.parse(raw);
|
||||
if (!s || s.v !== 1) return;
|
||||
|
||||
var root = document.documentElement;
|
||||
var path = location.pathname;
|
||||
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
|
||||
|
||||
var dark;
|
||||
if (isShared) dark = false;
|
||||
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
else dark = s.darkMode === true || s.darkMode === 'dark';
|
||||
root.classList.toggle('dark', dark);
|
||||
|
||||
var scheme = isShared ? 'default' : s.scheme;
|
||||
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
|
||||
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
|
||||
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
|
||||
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
|
||||
|
||||
if (!isShared && scheme === 'custom' && s.accent) {
|
||||
root.style.setProperty('--accent-custom-light', s.accent.light);
|
||||
root.style.setProperty('--accent-custom-dark', s.accent.dark);
|
||||
if (s.accentText) {
|
||||
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
|
||||
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
|
||||
}
|
||||
}
|
||||
|
||||
var ts = s.typeScale || {};
|
||||
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
|
||||
setScale('--fs-scale-title', fs * (ts.title || 1));
|
||||
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
|
||||
setScale('--fs-scale-body', fs * (ts.body || 1));
|
||||
setScale('--fs-scale-caption', fs * (ts.caption || 1));
|
||||
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
|
||||
|
||||
function setScale(name, v) {
|
||||
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
|
||||
}
|
||||
} catch (e) {
|
||||
/* never block boot */
|
||||
}
|
||||
})();
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theme:lint — guards the appearance token system.
|
||||
*
|
||||
* Flags styling that bypasses the design tokens and therefore won't follow a
|
||||
* user's chosen scheme / transparency / text-size:
|
||||
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
|
||||
* - inline numeric fontSize (fontSize: 13)
|
||||
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
|
||||
*
|
||||
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
|
||||
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
|
||||
*
|
||||
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
|
||||
* `--strict` exits non-zero when any violations remain (for once the backlog is
|
||||
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
|
||||
* line comment to suppress an intentional exception (map/PDF/brand colors).
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
let SRC = new URL('../src', import.meta.url).pathname;
|
||||
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
|
||||
|
||||
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
|
||||
// paint, standalone PDF documents) — colors there must stay literal.
|
||||
const EXEMPT = [
|
||||
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
|
||||
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
|
||||
/ReservationOverlay/, /\.test\./, /\.spec\./,
|
||||
];
|
||||
|
||||
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
|
||||
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
|
||||
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, files);
|
||||
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const strict = process.argv.includes('--strict');
|
||||
const offenders = [];
|
||||
let total = 0;
|
||||
|
||||
for (const f of walk(SRC)) {
|
||||
if (EXEMPT.some((re) => re.test(f))) continue;
|
||||
let count = 0;
|
||||
for (const line of readFileSync(f, 'utf8').split('\n')) {
|
||||
if (line.includes('theme-lint-disable')) continue;
|
||||
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
|
||||
}
|
||||
if (count) {
|
||||
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
|
||||
offenders.sort((a, b) => b[1] - a[1]);
|
||||
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
|
||||
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
|
||||
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
|
||||
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
|
||||
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
|
||||
|
||||
if (strict && total > 0) {
|
||||
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
|
||||
process.exit(1);
|
||||
}
|
||||
+21
-74
@@ -2,10 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import { usePluginStore } from './store/pluginStore'
|
||||
import PluginPage from './pages/PluginPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
@@ -15,19 +12,14 @@ import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import HelpPage from './pages/HelpPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import CollectionsPage from './pages/CollectionsPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import JoinTripPage from './pages/JoinTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
@@ -88,7 +80,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh md:block md:h-auto">
|
||||
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
@@ -113,7 +105,6 @@ export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -171,7 +162,6 @@ export default function App() {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -184,21 +174,30 @@ export default function App() {
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
applyAppearance({
|
||||
darkMode: settings.dark_mode,
|
||||
appearance: settings.appearance,
|
||||
isSharedPage,
|
||||
})
|
||||
run()
|
||||
// Re-resolve on OS theme change while in auto mode.
|
||||
if (!isSharedPage && settings.dark_mode === 'auto') {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
|
||||
}
|
||||
|
||||
if (mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => run()
|
||||
applyDark(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
@@ -209,8 +208,6 @@ export default function App() {
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
{!isAuthPage && <SaveToCollectionModal />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
@@ -230,32 +227,6 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
|
||||
visitor is redirected to /login (never registration) and returns here. */}
|
||||
<Route
|
||||
path="/join/:token"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<JoinTripPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
@@ -288,14 +259,6 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
@@ -328,22 +291,6 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
|
||||
+48
-354
@@ -15,23 +15,21 @@ import {
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
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 PackingSetSharingRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
@@ -43,10 +41,9 @@ import {
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -103,7 +100,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
sv: 'För många försök. Prova igen senare.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
@@ -178,17 +174,13 @@ apiClient.interceptors.response.use(
|
||||
// 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) {
|
||||
// Only an actual edge-proxy auth wall warrants tearing down the SW to
|
||||
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
|
||||
// with a cross-origin redirect or an HTML login page. A genuine offline
|
||||
// boot ALSO lands here — navigator.onLine reflects a network interface,
|
||||
// not reachability, and is routinely true on mobile while offline. So
|
||||
// gate strictly on a positive proxy signal; on plain offline do nothing
|
||||
// and let the request reject so the cached shell + IndexedDB serve the
|
||||
// app. Unregistering the SW here reloaded into a dead network and broke
|
||||
// PWA offline mode (#1346).
|
||||
const state = await probeNow()
|
||||
if (state === 'proxy-wall') {
|
||||
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')
|
||||
@@ -239,40 +231,6 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST a FormData body — the ONLY way this client should upload a file.
|
||||
*
|
||||
* The shared axios instance carries `timeout: 8000`, and axios' timeout is a whole-
|
||||
* request deadline rather than an idle one. A file upload that takes longer than 8s to
|
||||
* push its body — a phone photo on a slow uplink, a 500 MB document — is aborted
|
||||
* mid-stream, which the server reports as a multer "Request aborted" (#1495).
|
||||
*
|
||||
* Every upload therefore has to opt out with `timeout: 0`. That opt-out used to be
|
||||
* hand-written per call site, so it was forgotten on 7 of 15 — including the two 500 MB
|
||||
* endpoints (documents, backup restore). Centralizing makes the correct behavior the
|
||||
* default instead of something you have to remember.
|
||||
*
|
||||
* The Content-Type is set for clarity only: axios unsets it for FormData in the browser
|
||||
* so the platform can generate the multipart boundary.
|
||||
*/
|
||||
export interface UploadOptions {
|
||||
onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void
|
||||
idempotencyKey?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export function postMultipart<T = any>(url: string, formData: FormData, opts?: UploadOptions): Promise<T> {
|
||||
return apiClient.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}),
|
||||
},
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data as T)
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
@@ -287,7 +245,7 @@ export const authApi = {
|
||||
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
|
||||
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
|
||||
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
|
||||
uploadAvatar: (formData: FormData) => postMultipart('/auth/avatar', formData),
|
||||
uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
|
||||
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
|
||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||
@@ -368,17 +326,12 @@ export const tripsApi = {
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).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),
|
||||
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
|
||||
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).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),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
|
||||
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
|
||||
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
|
||||
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
@@ -388,7 +341,6 @@ export const daysApi = {
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, 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),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
@@ -404,23 +356,21 @@ export const placesApi = {
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
return postMultipart(`/trips/${tripId}/places/import/gpx`, fd)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
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, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -442,14 +392,9 @@ export const packingApi = {
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
|
||||
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
|
||||
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
|
||||
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number, visibility: 'common' | 'personal' = 'common') => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`, { visibility } satisfies PackingApplyTemplateRequest).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),
|
||||
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),
|
||||
@@ -494,69 +439,6 @@ export const adminApi = {
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
|
||||
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
|
||||
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
|
||||
pluginRetrust: (id: string, version: string, publicKey: string) =>
|
||||
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
|
||||
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
||||
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
||||
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
|
||||
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
|
||||
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
|
||||
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
|
||||
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
|
||||
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
|
||||
// the plugin with the widened allow-list.
|
||||
pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> =>
|
||||
apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data),
|
||||
pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> =>
|
||||
apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data),
|
||||
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
|
||||
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
|
||||
// Local LLM (Ollama) management for the AI-parsing addon.
|
||||
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
|
||||
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
|
||||
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
|
||||
llmLocalPull: async (
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
|
||||
): Promise<void> => {
|
||||
const res = await fetch('/api/admin/llm/local/pull', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
@@ -580,8 +462,7 @@ export const adminApi = {
|
||||
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
||||
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||
listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data),
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
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),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
@@ -604,150 +485,6 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
/** A host-rendered column/action a plugin contributes into a native planner view
|
||||
* (reservations/places/day) via the tableContributor hook. Every field is bounded +
|
||||
* normalized server-side; a column url is guaranteed http/https/mailto. */
|
||||
export type ViewContribution =
|
||||
| { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger' }
|
||||
| { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string } }
|
||||
|
||||
/** A badge a plugin adds to a dashboard trip card via the tripCardProvider hook.
|
||||
* Bounded + normalized server-side; the url is guaranteed http/https/mailto. */
|
||||
export interface TripCardBadge {
|
||||
pluginId: string; tripId: number; id: string; label: string;
|
||||
value?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger'; url?: string;
|
||||
}
|
||||
|
||||
export interface PluginMapMarker {
|
||||
pluginId: string; id: string; lat: number; lng: number;
|
||||
label?: string; popupText?: string; url?: string; icon?: string;
|
||||
tone: 'default' | 'success' | 'warn' | 'danger'
|
||||
}
|
||||
|
||||
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
|
||||
* Server-normalized: counts + lengths are capped, cells are plain strings. */
|
||||
export interface PluginPdfSection {
|
||||
pluginId: string; title: string; paragraphs: string[];
|
||||
table?: { headers: string[]; rows: string[][] }
|
||||
}
|
||||
|
||||
/** A country tint layer an atlasLayerProvider plugin draws over the Atlas map for
|
||||
* the signed-in user. Codes are ISO alpha-2 (server-validated), tone enum-whitelisted. */
|
||||
export interface PluginAtlasLayer {
|
||||
pluginId: string; id: string; name?: string;
|
||||
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
|
||||
}
|
||||
|
||||
export interface PluginUserSettingField {
|
||||
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
|
||||
hint?: string | null; required?: boolean; secret?: boolean;
|
||||
options?: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
/** A button a plugin contributes to its own settings page ("Test connection"). */
|
||||
export interface PluginAction {
|
||||
key: string; label: string; hint?: string; danger: boolean
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
active: () => apiClient.get('/plugins').then(r => r.data),
|
||||
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// the server skips any slow/failing provider, so this only ever adds rows.
|
||||
placeDetails: (placeId: number) =>
|
||||
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
||||
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
|
||||
tripWarnings: (tripId: number) =>
|
||||
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
|
||||
// Host-rendered columns/actions plugins add into a native planner view via the
|
||||
// tableContributor hook. Fetched once per view, keyed by entityId; fail-safe.
|
||||
viewContributions: (view: 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos', tripId: number | string) =>
|
||||
apiClient.get(`/view-contributions/${view}/${tripId}`).then(r => r.data as { contributions: ViewContribution[] }),
|
||||
// Bounded markers plugins overlay on the trip map via the mapMarkerProvider hook
|
||||
// (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers).
|
||||
mapMarkers: (tripId: number | string) =>
|
||||
apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }),
|
||||
// Text-only sections plugins append to the trip PDF export via the
|
||||
// pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe.
|
||||
pdfSections: (tripId: number | string) =>
|
||||
apiClient.get(`/pdf-sections/${tripId}`).then(r => r.data as { sections: PluginPdfSection[] }),
|
||||
// Country tint layers plugins draw over the Atlas map for the signed-in user via
|
||||
// the atlasLayerProvider hook. No tripId — user-scoped server-side; fail-safe.
|
||||
atlasLayers: () =>
|
||||
apiClient.get('/atlas-layers').then(r => r.data as { layers: PluginAtlasLayer[] }),
|
||||
// Extra rows plugins add under a journal entry via the journalEntryProvider hook.
|
||||
// Same shape + hardening as placeDetails (label/value/allowlisted url); fail-safe.
|
||||
journalEntryRows: (entryId: number) =>
|
||||
apiClient.get(`/journal-entry-rows/${entryId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
||||
// Badges plugins add to the dashboard trip cards via the tripCardProvider hook.
|
||||
// One call for all visible cards; host access-checks each tripId + bounds every
|
||||
// field (label/value/tone/allowlisted url); fail-safe.
|
||||
tripCardContributions: (tripIds: Array<number | string>) =>
|
||||
apiClient.get(`/trip-card-contributions?tripIds=${tripIds.join(',')}`).then(r => r.data as { contributions: TripCardBadge[] }),
|
||||
// The signed-in user's OWN plugin activity log — every host-mediated action a
|
||||
// plugin took bound to them, across all plugins, newest first. The user-facing
|
||||
// half of the capability audit; what makes the broad read grants accountable.
|
||||
myActivity: (limit = 200) =>
|
||||
apiClient.get(`/plugin-activity?limit=${limit}`).then(r => r.data as { activity: Array<{ ts: string; plugin_id: string; plugin_name: string | null; method: string; resource: string | null; code: string }> }),
|
||||
// A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are
|
||||
// masked; the write only accepts declared user-scope keys.
|
||||
userSettings: (id: string) =>
|
||||
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
|
||||
fields: PluginUserSettingField[]
|
||||
config: Record<string, unknown>
|
||||
actions: PluginAction[]
|
||||
}),
|
||||
// Run a settings-page action the plugin declared ("Test connection"). It runs AS the
|
||||
// caller, so it reads the caller's own settings.
|
||||
runAction: (id: string, key: string) =>
|
||||
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
|
||||
.then(r => r.data as { ok: boolean; message?: string }),
|
||||
saveUserSettings: (id: string, config: Record<string, unknown>) =>
|
||||
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
||||
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
|
||||
oauthStatus: (id: string) =>
|
||||
apiClient.get(`/plugin-oauth/${id}/status`).then(r => r.data as { configured: boolean; connected: boolean }),
|
||||
oauthConnect: (id: string) =>
|
||||
apiClient.post(`/plugin-oauth/${id}/connect`).then(r => r.data as { authorizeUrl: string }),
|
||||
oauthDisconnect: (id: string) =>
|
||||
apiClient.post(`/plugin-oauth/${id}/disconnect`).then(r => r.data as { connected: boolean }),
|
||||
// Call one of a plugin's own declared routes through the host proxy. `sub` is
|
||||
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
|
||||
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
|
||||
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
|
||||
// same way the browser would before sending — and reject anything that escapes
|
||||
// the prefix or points off-origin. Without this a plugin could send
|
||||
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
|
||||
const prefix = `/api/plugins/${id}/`
|
||||
let resolved: URL
|
||||
try {
|
||||
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
|
||||
} catch {
|
||||
return Promise.reject(new Error('invalid plugin route'))
|
||||
}
|
||||
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
|
||||
return Promise.reject(new Error('plugin route escapes its namespace'))
|
||||
}
|
||||
const url = resolved.pathname.slice('/api'.length) + resolved.search
|
||||
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
||||
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||
// flights + import are added with the trip-planner import (P2)
|
||||
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
@@ -770,15 +507,23 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/entries/${entryId}/photos`, formData, opts),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/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),
|
||||
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),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
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),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
@@ -786,7 +531,7 @@ export const journeyApi = {
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
// Cover
|
||||
uploadCover: (id: number, formData: FormData) => postMultipart(`/journeys/${id}/cover`, formData),
|
||||
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
|
||||
// Contributors
|
||||
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
|
||||
@@ -811,11 +556,6 @@ export const mapsApi = {
|
||||
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 => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
@@ -833,8 +573,7 @@ export const budgetApi = {
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).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 } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
@@ -842,7 +581,9 @@ export const budgetApi = {
|
||||
|
||||
export const filesApi = {
|
||||
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
|
||||
upload: (tripId: number | string, formData: FormData, opts?: UploadOptions) => postMultipart(`/trips/${tripId}/files`, formData, opts),
|
||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-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),
|
||||
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||
@@ -861,31 +602,17 @@ export const reservationsApi = {
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
fd.append('mode', mode)
|
||||
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
|
||||
// global 8s default (a cold local model alone can take ~45s).
|
||||
return postMultipart(`/trips/${tripId}/reservations/import/booking`, fd)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
||||
// Start a background parse: returns a job id at once; progress + result arrive
|
||||
// over the WebSocket (import:progress / import:done / import:error).
|
||||
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
fd.append('mode', mode)
|
||||
return postMultipart(`/trips/${tripId}/reservations/import/booking/async`, fd)
|
||||
},
|
||||
// Poll a background job — recovery path when a WebSocket push was missed.
|
||||
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
|
||||
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
@@ -898,17 +625,6 @@ export const configApi = {
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export interface HelpNavItem { title: string; slug: string }
|
||||
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
|
||||
export interface HelpPageData { slug: string; title: string; markdown: string }
|
||||
|
||||
export const helpApi = {
|
||||
index: (): Promise<{ sections: HelpNavSection[] }> =>
|
||||
apiClient.get('/help/index').then(r => r.data),
|
||||
page: (slug: string): Promise<HelpPageData> =>
|
||||
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
@@ -940,7 +656,7 @@ export const collabApi = {
|
||||
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, 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),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
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),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
@@ -975,7 +691,7 @@ export const backupApi = {
|
||||
uploadRestore: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('backup', file)
|
||||
return postMultipart('/backup/upload-restore', form)
|
||||
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
|
||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||
@@ -988,34 +704,12 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
|
||||
export const tripInviteApi = {
|
||||
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
|
||||
createLink: (tripId: number | string, expires_in_days?: number | null) =>
|
||||
apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data),
|
||||
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data),
|
||||
preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data),
|
||||
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).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 => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
|
||||
testChannel: (channelId: string) =>
|
||||
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
|
||||
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import apiClient, { postMultipart } from './client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type {
|
||||
CollectionListResponse,
|
||||
CollectionDetailResponse,
|
||||
CollectionSaveResult,
|
||||
CollectionMembership,
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
CollectionRole,
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
const base = '/addons/collections'
|
||||
|
||||
/** Query for the library-wide "is this place already saved?" lookup. */
|
||||
export interface MembershipQuery {
|
||||
google_place_id?: string
|
||||
google_ftid?: string
|
||||
name?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
export interface CopyToTripResult {
|
||||
copied: number
|
||||
skipped: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
|
||||
* vacayStore api shape — each method returns the unwrapped response body and
|
||||
* uses `satisfies` on the request payloads so the shared Zod request types stay
|
||||
* the single source of truth.
|
||||
*/
|
||||
export const collectionsApi = {
|
||||
list: (): Promise<CollectionListResponse> =>
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
postMultipart(`${base}/${id}/cover`, formData),
|
||||
remove: (id: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
reorder: (orderedIds: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
|
||||
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
|
||||
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
|
||||
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
|
||||
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
|
||||
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
deletePlace: (pid: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
|
||||
deleteMany: (ids: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
|
||||
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
|
||||
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
|
||||
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
|
||||
acceptInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
declineInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
|
||||
leave: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
|
||||
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// FE-API-UPLOAD-001 to FE-API-UPLOAD-013
|
||||
//
|
||||
// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
|
||||
// deadline — not an idle one. Any upload whose body takes longer than 8s to push is
|
||||
// aborted mid-stream and the server reports a multer "Request aborted" (#1495).
|
||||
//
|
||||
// The original fix added `timeout: 0` to the three cover uploads by hand, which left the
|
||||
// same bug live on 7 other endpoints — including the two that accept 500 MB (documents
|
||||
// and backup restore). Every multipart call now goes through postMultipart(), so this
|
||||
// suite pins ALL of them, not just the covers.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
apiClient,
|
||||
authApi,
|
||||
tripsApi,
|
||||
placesApi,
|
||||
adminApi,
|
||||
journeyApi,
|
||||
filesApi,
|
||||
reservationsApi,
|
||||
collabApi,
|
||||
backupApi,
|
||||
} from './client'
|
||||
import { collectionsApi } from './collections'
|
||||
|
||||
describe('every multipart upload disables the global request timeout', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function spyPost() {
|
||||
return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any)
|
||||
}
|
||||
|
||||
const fd = () => new FormData()
|
||||
const file = () => new File(['x'], 'f.bin')
|
||||
|
||||
// [id, description, invoke, expected url]
|
||||
const cases: [string, string, () => Promise<unknown>, string][] = [
|
||||
['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'],
|
||||
['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'],
|
||||
['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'],
|
||||
['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'],
|
||||
['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'],
|
||||
['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'],
|
||||
['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'],
|
||||
['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'],
|
||||
['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'],
|
||||
['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'],
|
||||
['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'],
|
||||
['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'],
|
||||
]
|
||||
|
||||
for (const [id, desc, invoke, url] of cases) {
|
||||
it(`${id}: ${desc} posts with timeout 0`, async () => {
|
||||
const post = spyPost()
|
||||
await invoke()
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ timeout: 0 }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => {
|
||||
const post = spyPost()
|
||||
await reservationsApi.importBookingPreview(7, [file()])
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/trips/7/reservations/import/booking',
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ timeout: 0 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -20,12 +20,6 @@ export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||
* trip's store after the network comes back via the `online` event. */
|
||||
export function getActiveTrips(): string[] {
|
||||
return Array.from(activeTrips)
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -299,12 +298,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
|
||||
]
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const toast = useToast()
|
||||
const cfg = (addon.config ?? {}) as Record<string, unknown>
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
const [modelsErr, setModelsErr] = useState('')
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [pulling, setPulling] = useState<string | null>(null)
|
||||
const [pullPct, setPullPct] = useState(0)
|
||||
const [pullStatus, setPullStatus] = useState('')
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
|
||||
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return
|
||||
setLoadingModels(true)
|
||||
setModelsErr('')
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl)
|
||||
setInstalled(res.models.map(m => m.name))
|
||||
} catch (e: unknown) {
|
||||
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
|
||||
setInstalled([])
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load installed models when the local provider is active.
|
||||
useEffect(() => {
|
||||
if (provider === 'local') loadModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider])
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return
|
||||
setPulling(id)
|
||||
setPullPct(0)
|
||||
setPullStatus('starting…')
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error)
|
||||
if (p.status) setPullStatus(p.status)
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
|
||||
})
|
||||
toast.success('Model pulled')
|
||||
setModel(id)
|
||||
await loadModels()
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed')
|
||||
} finally {
|
||||
setPulling(null)
|
||||
setPullPct(0)
|
||||
setPullStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
||||
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<p className="text-xs text-content-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import AdminPluginsPanel from './AdminPluginsPanel'
|
||||
|
||||
/**
|
||||
* The "allowed hosts" chip. A plugin that talks to a SELF-HOSTED service (a Gotify) can't
|
||||
* name the operator's host in its manifest, so the admin adds it — but they'd never know
|
||||
* that unless the card says so. Until a host exists the plugin can reach NOTHING and looks
|
||||
* silently broken, which is why the chip is warning-toned and actionable in that state.
|
||||
*/
|
||||
function plugin(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'trek-gotify', name: 'Gotify', description: 'Push notifications', type: 'integration',
|
||||
icon: 'Bell', version: '1.0.0', status: 'active', enabled: 1,
|
||||
last_error: null, reviewed_at: null, source_repo: null,
|
||||
permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']),
|
||||
capabilities: '{}',
|
||||
operatorEgress: true,
|
||||
egressHostCount: 0,
|
||||
dependencyStatus: 'ok',
|
||||
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] },
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mockList(p: Record<string, unknown>) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] })),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
describe('AdminPluginsPanel — allowed-hosts chip', () => {
|
||||
it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => {
|
||||
mockList(plugin({ egressHostCount: 0 }))
|
||||
render(<AdminPluginsPanel />)
|
||||
// The plugin can't reach anything yet — the card must say so, not stay silent.
|
||||
expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-EGRESS-002: shows the count once hosts exist', async () => {
|
||||
mockList(plugin({ egressHostCount: 2 }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-EGRESS-003: a plugin that never declared operatorEgress gets NO chip', async () => {
|
||||
mockList(plugin({ operatorEgress: false }))
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
// An admin must never be invited to widen egress for a plugin that didn't ask for it.
|
||||
expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-EGRESS-004: clicking the chip opens the allowed-hosts dialog', async () => {
|
||||
mockList(plugin({ egressHostCount: 1 }))
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
||||
HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i }))
|
||||
await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The Discover (pre-install) modal. Its "Connects to" list is what a reviewer reads to
|
||||
* judge a plugin's network reach — so for an operatorEgress plugin that list is NOT the
|
||||
* whole story, and saying nothing would actively mislead them.
|
||||
*/
|
||||
function mockDetail(manifest: Record<string, unknown> | null) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
||||
// pluginBrowse returns the ARRAY itself, not { plugins: [...] }.
|
||||
http.get('*/api/admin/plugins/registry', () =>
|
||||
HttpResponse.json([{ id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', type: 'integration', tags: [] }])),
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () =>
|
||||
HttpResponse.json({
|
||||
id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify',
|
||||
type: 'integration', tags: [], size: 1024, publishedAt: null, latest: '1.0.0', manifest,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
|
||||
const base = { permissions: ['hook:notification-channel', 'http:outbound:gotify.net'], egress: ['gotify.net'], settings: [], license: 'MIT', icon: null }
|
||||
|
||||
/** The panel opens on Installed — switch to Discover, then open the plugin's card. */
|
||||
async function openDetail() {
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
|
||||
fireEvent.click(await screen.findByText('Gotify'))
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-EGRESS-005: warns that the host list is not the whole story', async () => {
|
||||
mockDetail({ ...base, operatorEgress: true })
|
||||
await openDetail()
|
||||
|
||||
// The declared host is still listed…
|
||||
expect(await screen.findByText('gotify.net')).toBeInTheDocument()
|
||||
// …alongside the pill saying an admin adds more.
|
||||
expect(screen.getByText(/hosts you add/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-EGRESS-006: an ordinary plugin gets NO such pill', async () => {
|
||||
mockDetail({ ...base, operatorEgress: false })
|
||||
await openDetail()
|
||||
|
||||
expect(await screen.findByText('gotify.net')).toBeInTheDocument()
|
||||
// Its egress list IS the whole story — claiming otherwise would be a lie.
|
||||
expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* #1523. The row's ⋯ menu used to be an in-flow `absolute` div, and PageSidebar — the
|
||||
* panel's ancestor — is `overflow-hidden`. On the lower rows of a long plugin list the
|
||||
* menu was clipped mid-way, taking Delete with it: the plugin became uninstallable from
|
||||
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
|
||||
*/
|
||||
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
|
||||
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
|
||||
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
|
||||
|
||||
/** Put the ⋯ button wherever we want in an 800px-tall viewport. */
|
||||
function stubTriggerAt(top: number) {
|
||||
window.innerHeight = 800
|
||||
window.innerWidth = 1200
|
||||
HTMLButtonElement.prototype.getBoundingClientRect = function () {
|
||||
return { top, bottom: top + 34, left: 1100, right: 1134, width: 34, height: 34, x: 1100, y: top, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
}
|
||||
|
||||
async function openRowMenu() {
|
||||
mockList(withRepo)
|
||||
const { container } = render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
return { container, menu: screen.getByTestId('plugin-row-menu-trek-gotify') }
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-001: renders every action, including Delete', async () => {
|
||||
stubTriggerAt(100)
|
||||
await openRowMenu()
|
||||
|
||||
for (const label of [/restart/i, /error log/i, /allowed hosts/i, /source repository/i, /report an issue/i, /delete/i]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-002: is portaled out of the panel, so no overflow ancestor can clip it', async () => {
|
||||
stubTriggerAt(100)
|
||||
const { container, menu } = await openRowMenu()
|
||||
|
||||
// THE regression guard: living inside the panel is exactly what got it clipped.
|
||||
expect(container.contains(menu)).toBe(false)
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(menu.style.position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-003: hangs below the ⋯ when there is room', async () => {
|
||||
stubTriggerAt(100)
|
||||
const { menu } = await openRowMenu()
|
||||
|
||||
expect(menu.style.top).toBe('138px') // trigger bottom (134) + 4
|
||||
expect(menu.style.bottom).toBe('')
|
||||
expect(menu.style.right).toBe('66px') // viewport (1200) - trigger right (1134)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-004: flips upward for a row near the bottom — the #1523 case', async () => {
|
||||
stubTriggerAt(700) // 66px of room below: the six-item menu would run off-screen
|
||||
const { menu } = await openRowMenu()
|
||||
|
||||
expect(menu.style.bottom).toBe('104px') // viewport (800) - trigger top (700) + 4
|
||||
expect(menu.style.top).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Signature status (#plugins). TREK has always verified author signatures and TOFU-pinned
|
||||
* the key — and never showed any of it, so a successfully-installed UNSIGNED plugin looked
|
||||
* identical to a signed one, forever.
|
||||
*
|
||||
* The two tests that matter most here are the ones guarding the override: a re-trust is
|
||||
* offered for a ROTATED key (benign explanation) and for NOTHING else. A signature that
|
||||
* doesn't verify means the bytes are not what the author signed, and there is no story
|
||||
* where the right answer is letting the admin wave it through.
|
||||
*/
|
||||
function registryEntry(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
|
||||
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
|
||||
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mockPanel(p: Record<string, unknown>, entry: Record<string, unknown> | null = registryEntry()) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AdminPluginsPanel — signature badges', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-001: a registry plugin with a pinned key reads as Signed', async () => {
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: true, keyFingerprint: 'AAAAAAAA…BBBBBBBB' }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Signed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-002: a registry plugin with no key reads as Unsigned', async () => {
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false, keyFingerprint: null }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Unsigned')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The precedence rule. `signed` derives from the pinned key, sideloaded from source_repo
|
||||
// — so they are NOT mutually exclusive in the data, and a sideloaded plugin genuinely has
|
||||
// no key. Rendering "Unsigned" NEXT TO "Sideloaded" would double up on a plugin whose
|
||||
// badge already says something strictly stronger, diluting the amber into wallpaper.
|
||||
it('FE-COMP-PLUGINS-SIG-003: a sideloaded plugin shows Sideloaded and NO trust badge', async () => {
|
||||
mockPanel(plugin({ source_repo: 'local:upload', signed: false }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Sideloaded')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Signed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-004: a dev-linked plugin shows Dev-Link and NO trust badge', async () => {
|
||||
mockPanel(plugin({ source_repo: 'local:link', signed: false }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Dev-Link')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — a refused update', () => {
|
||||
const blocked = (code: string) =>
|
||||
plugin({
|
||||
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
|
||||
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-005: the row keeps showing WHY, instead of the reason dying with a toast', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/update blocked/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The block describes the version that was REFUSED. Once the registry offers a newer one,
|
||||
// it describes an artifact nobody is being offered anymore — so it reads as stale and the
|
||||
// admin can simply re-attempt.
|
||||
it('FE-COMP-PLUGINS-SIG-006: the block goes quiet once a NEWER version is on offer', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'), registryEntry({ latest: '3.0.0' }))
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
await waitFor(() => expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-007: Review opens the re-trust dialog for a ROTATED key', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
// Both fingerprints, so the admin can compare them against what the author tells them.
|
||||
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
|
||||
// There is no override — not a disabled button, not one behind a confirm. The ABSENCE of
|
||||
// an escape hatch is the feature. (The server refuses it too; this is belt and braces.)
|
||||
it('FE-COMP-PLUGINS-SIG-008: an INVALID signature offers NO re-trust affordance at all', async () => {
|
||||
mockPanel(blocked('SIGNATURE_INVALID'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
await screen.findByText(/do not match the author's signature/i)
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
// ...and it does not even show the key comparison, which would imply a choice exists.
|
||||
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-009: an unsigned-downgrade refusal offers no override either', async () => {
|
||||
mockPanel(blocked('SIGNATURE_MISSING'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
await screen.findByText(/ships no signature/i)
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-010: confirming a re-trust re-pins AND updates in ONE call', async () => {
|
||||
let body: unknown = null
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
|
||||
|
||||
// The FULL key goes back, not the fingerprint: the server's equality check is exact, so
|
||||
// it can refuse if the entry was re-keyed again since this dialog rendered.
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ version: '2.0.0', publicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }),
|
||||
)
|
||||
// No follow-up /update: a re-pin that waited for a second call would leave the plugin
|
||||
// pinned to a key no install had ever verified against if that call never came.
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — update consent', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-011: says an unsigned update is untied to its author, and still activates in one click', async () => {
|
||||
let activated = false
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false }), registryEntry({ signed: false }))
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
||||
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }),
|
||||
),
|
||||
http.post('*/api/admin/plugins/trek-gotify/activate', () => { activated = true; return HttpResponse.json({ status: 'active' }) }),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }))
|
||||
|
||||
// Informs — it does not block. No checkbox, no second click.
|
||||
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /approve & turn on/i }))
|
||||
await waitFor(() => expect(activated).toBe(true))
|
||||
})
|
||||
|
||||
// The warning used to be read ONLY off the registry entry, so an unreachable registry left
|
||||
// it undefined and the pill silently vanished — at the exact moment the admin was widening
|
||||
// what unsigned code may do. The installed row carries an authoritative `signed` from the
|
||||
// server on every list call; degrade to that rather than to silence.
|
||||
//
|
||||
// Consent is reached here by turning a plugin back ON after an update widened its
|
||||
// permissions (409 CONSENT_REQUIRED) — which is the path that still works with the registry
|
||||
// down, precisely because it needs nothing from the registry.
|
||||
it('FE-COMP-PLUGINS-SIG-015: the unsigned warning survives an unreachable registry', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () =>
|
||||
HttpResponse.json({
|
||||
enabled: true, devLink: false,
|
||||
plugins: [plugin({ source_repo: 'acme/gotify', signed: false, enabled: 0, status: 'inactive', operatorEgress: false })],
|
||||
})),
|
||||
// The registry is down: `regById` stays empty, so the entry's `signed` is unknowable.
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 500 })),
|
||||
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
||||
HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
|
||||
|
||||
// Falls back to the installed row's `signed: false` rather than going quiet.
|
||||
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A signature refusal must reach the dialog even when the plugin has NO installed row —
|
||||
* which is every fresh install from Discover, and every dependency being downloaded.
|
||||
*
|
||||
* Routing the refusal off the installed list meant those two paths silently fell back to a
|
||||
* generic toast: the admin met SIGNATURE_INVALID for the first time on the one path where the
|
||||
* dialog explaining it never opened. A fresh install has no pinned key, so it can only ever
|
||||
* be _INVALID / _INCOMPLETE — never a rotation — and both are non-overridable, so the dialog
|
||||
* must explain and offer nothing.
|
||||
*/
|
||||
describe('AdminPluginsPanel — a refusal with no installed row', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-013: a fresh install refused for an INVALID signature opens the dialog, not a toast', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
|
||||
http.post('*/api/admin/plugins/install', () =>
|
||||
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||
|
||||
// The dialog, named after the plugin — which it can only know from the REGISTRY entry,
|
||||
// there being no installed row to read a name off.
|
||||
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
|
||||
await screen.findByText(/do not match the author's signature/i)
|
||||
// Non-overridable, and no key comparison — showing one would imply a choice exists.
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-014: a refusal while downloading a DEPENDENCY opens the dialog too', async () => {
|
||||
const parent = plugin({ id: 'trek-parent', name: 'Parent', source_repo: 'acme/parent', enabled: 0, status: 'inactive', operatorEgress: false })
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [parent] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
|
||||
// Turning it on reveals the missing dependency…
|
||||
http.post('*/api/admin/plugins/trek-parent/activate', () =>
|
||||
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-gotify', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
||||
// …and downloading it is refused on its signature.
|
||||
http.post('*/api/admin/plugins/install', () =>
|
||||
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /download/i }))
|
||||
|
||||
// Named after the DEPENDENCY, not the parent — it is the dependency's author whose
|
||||
// signature did not verify, and saying "Parent" here would point the admin at the wrong
|
||||
// plugin entirely.
|
||||
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — a block never outlives the registry relationship', () => {
|
||||
// The server clears the block on sideload/dev-link. This is the belt: even if a stale
|
||||
// block somehow reached the client, a plugin whose code the admin supplied by hand must
|
||||
// never claim an update was blocked over an author signing key.
|
||||
it('FE-COMP-PLUGINS-SIG-012: a sideloaded plugin never shows an update block', async () => {
|
||||
mockPanel(plugin({
|
||||
source_repo: 'local:upload', signed: false,
|
||||
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
|
||||
}))
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Sideloaded')
|
||||
expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* TREK-version compatibility. The SERVER owns the semver — a second implementation in the
|
||||
* browser would eventually disagree with the install gate and offer a button that 400s —
|
||||
* so the panel only renders the verdict the API hands it (`compatible`, `latestCompatible`).
|
||||
*/
|
||||
describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
||||
/** Discover cards for a plugin that is NOT installed — an installed one just reads "Installed". */
|
||||
async function openDiscover(entry: Record<string, unknown>) {
|
||||
mockPanel(plugin({ id: 'something-else' }), registryEntry(entry))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
}
|
||||
|
||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||
await openDiscover({ latest: '2.0.0', trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })
|
||||
const btn = await screen.findByRole('button', { name: /^install 1\.5\.0$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
})
|
||||
|
||||
it('installs normally when the latest version fits', async () => {
|
||||
await openDiscover({ trek: '>=3.2.0 <4.0.0', hostVersion: '3.3.0', compatible: true, latestCompatible: '2.0.0' })
|
||||
expect(await screen.findByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('an installed plugin the server has outgrown shows the blocker on its card', async () => {
|
||||
// Same amber chip machinery as a disabled addon / missing dependency — the admin sees
|
||||
// one "here is why this cannot turn on" surface, not a new concept per blocker.
|
||||
mockPanel(plugin({
|
||||
dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0', enabled: 0, status: 'inactive',
|
||||
}))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -473,10 +473,10 @@ export default function BackupPanel() {
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
@@ -484,11 +484,11 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
@@ -500,14 +500,14 @@ export default function BackupPanel() {
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
|
||||
@@ -6,17 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
getStylePresets,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -28,30 +18,10 @@ const MAP_PRESETS = [
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
maplibre_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
type MapProvider = 'leaflet' | GlMapProvider
|
||||
|
||||
function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
@@ -89,7 +59,7 @@ function OptionButton({
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
@@ -107,16 +77,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
@@ -136,11 +101,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
}
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
@@ -186,24 +146,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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 mapProvider = normalizeProvider(defaults.map_provider)
|
||||
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
|
||||
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||
const saveMapProvider = (nextProvider: MapProvider) => {
|
||||
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
||||
if (nextProvider !== 'leaflet') {
|
||||
// Load + save the new provider's own style slot so the other provider's style is kept.
|
||||
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
||||
const nextStyle = styleForProvider(nextProvider, slot)
|
||||
setMapboxStyle(nextStyle)
|
||||
patch[styleSettingKey(nextProvider)] = nextStyle
|
||||
}
|
||||
save(patch)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
@@ -244,22 +190,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Distance */}
|
||||
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
{([
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.distance_unit === opt.value}
|
||||
onClick={() => save({ distance_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
@@ -276,23 +206,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
@@ -354,105 +267,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={mapProvider === opt.value}
|
||||
onClick={() => saveMapProvider(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
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 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field={styleKey} />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
setMapboxStyle(nextStyle)
|
||||
save({ [styleKey]: nextStyle })
|
||||
}}
|
||||
placeholder={defaultStyleForProvider(mapProvider)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ 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.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ 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.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,187 +1,146 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'liketrek/TREK';
|
||||
const PER_PAGE = 10;
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
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;
|
||||
id: number
|
||||
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
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setReleases((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(data.length === PER_PAGE);
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchReleases(1).finally(() => setLoading(false));
|
||||
}, []);
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1;
|
||||
setLoadingMore(true);
|
||||
await fetchReleases(next, true);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
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) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listItems = [];
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
)
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
});
|
||||
};
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs text-content-muted"
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
};
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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"
|
||||
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' }}
|
||||
>
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -194,28 +153,11 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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"
|
||||
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' }}
|
||||
>
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -228,31 +170,12 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
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"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
@@ -262,33 +185,16 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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"
|
||||
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' }}
|
||||
>
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -298,31 +204,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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"
|
||||
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' }}
|
||||
>
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -332,31 +221,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
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';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
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"
|
||||
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' }}
|
||||
>
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -369,30 +241,30 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="mt-1 text-xs text-content-faint">{error}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
|
||||
<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 border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-content-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>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted 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"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
@@ -403,21 +275,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
@@ -429,24 +298,28 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
@@ -463,31 +336,33 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted 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"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
@@ -498,5 +373,5 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
import BackgroundTasksWidget from './BackgroundTasksWidget'
|
||||
|
||||
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({
|
||||
// Keep the rehydrate/poll backstops pending so the seeded state is what renders.
|
||||
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})) },
|
||||
}))
|
||||
|
||||
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
|
||||
id: 'j1',
|
||||
tripId: 't1',
|
||||
label: 'voucher.pdf',
|
||||
status: 'done',
|
||||
done: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useBackgroundTasksStore.setState({ tasks: [] })
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget', () => {
|
||||
it('shows the warnings when a finished job produced no items', () => {
|
||||
const warning = 'voucher.pdf: AI parsing failed — LLM request failed (400): response_format unsupported'
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ warnings: [warning] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(screen.getByText(warning)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the empty-preview note when there are no warnings', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task()] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
* Global, route-independent widget (bottom-right) that tracks background booking
|
||||
* imports. Mounted once at the app root so it survives navigation. It listens to the
|
||||
* user's WebSocket for import:progress / import:done / import:error and reflects each
|
||||
* job; a finished job offers a "review" action that takes the user to the trip, where
|
||||
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
|
||||
*/
|
||||
export default function BackgroundTasksWidget() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const tasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
|
||||
const setDone = useBackgroundTasksStore((s) => s.setDone)
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
|
||||
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
|
||||
// that was still running when the page reloaded must keep its widget, so re-fetch each
|
||||
// job's real status (and its parsed items) once. A job the server has since dropped
|
||||
// (404, expired) is removed so no stale card lingers.
|
||||
const didRehydrate = useRef(false)
|
||||
useEffect(() => {
|
||||
if (didRehydrate.current) return
|
||||
didRehydrate.current = true
|
||||
const restored = useBackgroundTasksStore.getState().tasks
|
||||
for (const task of restored) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
if (err?.response?.status === 404) dismiss(task.id)
|
||||
})
|
||||
}
|
||||
// run once on mount against whatever was rehydrated from storage
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Server pushes import:* to the user on whatever page they're on.
|
||||
useEffect(() => {
|
||||
const handler = (e: Record<string, unknown>) => {
|
||||
const type = typeof e.type === 'string' ? e.type : ''
|
||||
if (!type.startsWith('import:')) return
|
||||
const id = String(e.jobId ?? '')
|
||||
const tripId = String(e.tripId ?? '')
|
||||
if (!id) return
|
||||
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
|
||||
else if (type === 'import:done') {
|
||||
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
|
||||
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
|
||||
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [setProgress, setDone, setError])
|
||||
|
||||
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
|
||||
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
|
||||
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
|
||||
if (pending.length === 0) return
|
||||
const iv = setInterval(() => {
|
||||
for (const task of pending) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const review = (task: BackgroundImportTask) => {
|
||||
requestReview(task.id)
|
||||
navigate(`/trips/${task.tripId}`)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.label}
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.parsing')}
|
||||
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'done' && (
|
||||
task.items === undefined ? (
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
{(task.warnings?.length ?? 0) > 0 && (
|
||||
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
|
||||
{task.warnings!.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.status === 'error' && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,72 +1,23 @@
|
||||
// The full set of currencies the Frankfurter v2 FX API supports (archived codes
|
||||
// excluded), so every selectable currency actually converts. Regenerate from
|
||||
// `GET https://api.frankfurter.dev/v2/currencies?expand=providers` (iso_code +
|
||||
// symbol) if the provider's list changes. See issue #1470.
|
||||
export const CURRENCIES = [
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN',
|
||||
'BAM', 'BBD', 'BDT', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD',
|
||||
'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNH', 'CNY',
|
||||
'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP',
|
||||
'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP',
|
||||
'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HTG', 'HUF', 'IDR', 'ILS',
|
||||
'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES',
|
||||
'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP',
|
||||
'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT',
|
||||
'MOP', 'MRO', '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',
|
||||
'SVC', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD',
|
||||
'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV',
|
||||
'WST', 'XAF', 'XAG', 'XAU', 'XCD', 'XCG', 'XDR', 'XOF', 'XPD', 'XPF',
|
||||
'XPT', 'YER', 'ZAR', 'ZMW', 'ZWG',
|
||||
'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> = {
|
||||
AED: 'د.إ', AFN: '؋', ALL: 'L', AMD: '֏', ANG: 'ƒ',
|
||||
AOA: 'Kz', ARS: '$', AUD: '$', AWG: 'ƒ', AZN: '₼',
|
||||
BAM: 'КМ', BBD: '$', BDT: '৳', BHD: 'د.ب', BIF: 'Fr',
|
||||
BMD: '$', BND: '$', BOB: 'Bs.', BRL: 'R$', BSD: '$',
|
||||
BTN: 'Nu.', BWP: 'P', BYN: 'Br', BZD: '$', CAD: '$',
|
||||
CDF: 'Fr', CHF: 'CHF', CLP: '$', CNH: '¥', CNY: '¥',
|
||||
COP: '$', CRC: '₡', CUP: '$', CVE: '$', CZK: 'Kč',
|
||||
DJF: 'Fdj', DKK: 'kr.', DOP: '$', DZD: 'د.ج', EGP: 'ج.م',
|
||||
ERN: 'Nfk', ETB: 'Br', EUR: '€', FJD: '$', FKP: '£',
|
||||
GBP: '£', GEL: '₾', GGP: '£', GHS: '₵', GIP: '£',
|
||||
GMD: 'D', GNF: 'Fr', GTQ: 'Q', GYD: '$', HKD: '$',
|
||||
HNL: 'L', HTG: 'G', HUF: 'Ft', IDR: 'Rp', ILS: '₪',
|
||||
IMP: '£', INR: '₹', IQD: 'ع.د', IRR: '﷼', ISK: 'kr.',
|
||||
JEP: '£', JMD: '$', JOD: 'د.ا', JPY: '¥', KES: 'KSh',
|
||||
KGS: 'som', KHR: '៛', KMF: 'Fr', KPW: '₩', KRW: '₩',
|
||||
KWD: 'د.ك', KYD: '$', KZT: '₸', LAK: '₭', LBP: 'ل.ل',
|
||||
LKR: '₨', LRD: '$', LSL: 'L', LYD: 'ل.د', MAD: 'د.م.',
|
||||
MDL: 'L', MGA: 'Ar', MKD: 'ден', MMK: 'K', MNT: '₮',
|
||||
MOP: 'P', MRO: 'UM', MRU: 'UM', MUR: '₨', MVR: 'MVR',
|
||||
MWK: 'MK', MXN: '$', MYR: 'RM', MZN: 'MTn', NAD: '$',
|
||||
NGN: '₦', NIO: 'C$', NOK: 'kr', NPR: 'Rs.', NZD: '$',
|
||||
OMR: 'ر.ع.', PAB: 'B/.', PEN: 'S/', PGK: 'K', PHP: '₱',
|
||||
PKR: '₨', PLN: 'zł', PYG: '₲', QAR: 'ر.ق', RON: 'Lei',
|
||||
RSD: 'RSD', RUB: '₽', RWF: 'FRw', SAR: 'ر.س', SBD: '$',
|
||||
SCR: '₨', SDG: '£', SEK: 'kr', SGD: '$', SHP: '£',
|
||||
SLE: 'Le', SOS: 'Sh', SRD: '$', SSP: '£', STN: 'Db',
|
||||
SVC: '₡', SYP: '£S', SZL: 'E', THB: '฿', TJS: 'ЅМ',
|
||||
TMT: 'm', TND: 'د.ت', TOP: 'T$', TRY: '₺', TTD: '$',
|
||||
TWD: '$', TZS: 'Sh', UAH: '₴', UGX: 'USh', USD: '$',
|
||||
UYU: '$U', UZS: 'so\'m', VES: 'Bs', VND: '₫', VUV: 'Vt',
|
||||
WST: 'T', XAF: 'CFA', XAG: 'oz t', XAU: 'oz t', XCD: '$',
|
||||
XCG: 'Cg', XDR: 'SDR', XOF: 'Fr', XPD: 'oz t', XPF: 'Fr',
|
||||
XPT: 'oz t', YER: '﷼', ZAR: 'R', ZMW: 'K', ZWG: 'ZiG',
|
||||
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$',
|
||||
}
|
||||
|
||||
// Keep a currency the user already saved selectable even after it leaves the
|
||||
// supported set (e.g. archived BGN/HRK), so opening an existing item or settings
|
||||
// row doesn't silently blank the field and wipe the value on the next save.
|
||||
export function currenciesWith(current?: string | null): readonly string[] {
|
||||
const cur = (current || '').toUpperCase()
|
||||
return cur && !CURRENCIES.includes(cur) ? [...CURRENCIES, cur] : CURRENCIES
|
||||
}
|
||||
|
||||
export const PIE_COLORS =['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
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)' },
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers'
|
||||
|
||||
describe('BudgetPanel.helpers', () => {
|
||||
describe('hasCustomMemberSplit (#1458)', () => {
|
||||
it('is false when no members', () => {
|
||||
expect(hasCustomMemberSplit({})).toBe(false)
|
||||
expect(hasCustomMemberSplit({ members: [] })).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for an equal split (members carry no amount)', () => {
|
||||
expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: null }] })).toBe(false)
|
||||
expect(hasCustomMemberSplit({ members: [{}, {}] })).toBe(false)
|
||||
})
|
||||
|
||||
it('is true as soon as any member has a custom amount', () => {
|
||||
expect(hasCustomMemberSplit({ members: [{ amount: 90 }, { amount: 10 }] })).toBe(true)
|
||||
expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: 10 }] })).toBe(true)
|
||||
expect(hasCustomMemberSplit({ members: [{ amount: 0 }] })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('calcPP still averages the total for equal splits', () => {
|
||||
expect(calcPP(100, 2)).toBe(50)
|
||||
expect(calcPP(100, 0)).toBeNull()
|
||||
expect(calcPP(100, null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -64,12 +64,6 @@ export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) /
|
||||
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)
|
||||
|
||||
// A custom (uneven) split has no single "per person" figure — one member's share
|
||||
// differs from another's — so the averaged per-person columns are meaningless for it
|
||||
// (the per-member amounts are shown via the member chips instead). #1458
|
||||
export const hasCustomMemberSplit = (item: { members?: { amount?: number | null }[] }) =>
|
||||
(item.members || []).some(m => m.amount != null)
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Plus, Calculator, Download } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants'
|
||||
import { CURRENCIES, SYMBOLS } from './BudgetPanel.constants'
|
||||
import { useBudgetPanel } from './useBudgetPanel'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import BudgetCategoryTable from './BudgetPanelCategoryTable'
|
||||
@@ -38,14 +38,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Calculator size={28} color="#6b7280" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
@@ -65,7 +65,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
@@ -74,7 +74,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
@@ -85,14 +85,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
title={t('budget.addCategory')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||
transition: 'opacity 0.15s ease',
|
||||
@@ -105,7 +105,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
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">
|
||||
@@ -44,9 +44,9 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</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 />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Fragment, type CSSProperties, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers'
|
||||
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||
import AddItemRow from './BudgetPanelAddItemRow'
|
||||
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -54,7 +53,6 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||
const items = grouped.get(cat) || []
|
||||
const contribFor = usePluginViewContributions('costs', tripId)
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = categoryColor(cat)
|
||||
return (
|
||||
@@ -105,11 +103,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))', background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
|
||||
<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 }}
|
||||
@@ -121,7 +119,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<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 }}
|
||||
@@ -151,17 +149,12 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
// A custom (uneven) split has no single per-person figure — the per-member
|
||||
// amounts are shown via the member chips — so blank those columns (#1458).
|
||||
const customSplit = hasCustomMemberSplit(item)
|
||||
const pp = customSplit ? null : calcPP(item.total_price, item.persons)
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
const hasMembers = (item.members?.length ?? 0) > 0
|
||||
const contributions = contribFor(item.id)
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<tr
|
||||
<tr key={item.id}
|
||||
style={{
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
opacity: dragItem === item.id ? 0.4 : 1,
|
||||
@@ -240,7 +233,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
<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>
|
||||
@@ -254,14 +247,6 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{contributions.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} style={{ padding: '0 8px 6px 20px' }}>
|
||||
<PluginCardFooter items={contributions} tripId={tripId} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
|
||||
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 || '-'}
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
@@ -57,13 +56,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
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: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
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>
|
||||
@@ -152,14 +151,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
|
||||
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: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
|
||||
@@ -51,10 +51,10 @@ export default function PerPersonInline({ tripId, budgetItems, currency, locale,
|
||||
<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: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
<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: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function PieChart({ segments, size = 200, totalLabel }: PieChartP
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
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: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
@@ -95,7 +95,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
>
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
<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' }}>
|
||||
@@ -130,7 +130,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
<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 }}>
|
||||
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))', color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em',
|
||||
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',
|
||||
}}>
|
||||
@@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
})}
|
||||
</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: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||
<div style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '-0.01em',
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
expect(splitCents(90, 3)).toEqual([30, 30, 30])
|
||||
})
|
||||
|
||||
it('distributes the remainder cents so the parts sum back exactly', () => {
|
||||
const parts = splitCents(100.01, 3)
|
||||
expect(parts).toEqual([33.34, 33.34, 33.33])
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBeCloseTo(100.01, 2)
|
||||
})
|
||||
|
||||
it('returns an empty list for a non-positive count', () => {
|
||||
expect(splitCents(50, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('floors a negative amount at zero rather than inventing debt', () => {
|
||||
expect(splitCents(-10, 2)).toEqual([0, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('payerSum', () => {
|
||||
it('sums only the selected payers', () => {
|
||||
const amounts = { 1: '45', 2: '45', 3: '99' }
|
||||
expect(payerSum(amounts, new Set([1, 2]))).toBeCloseTo(90, 2)
|
||||
})
|
||||
|
||||
it('treats blank and unparseable amounts as zero', () => {
|
||||
expect(payerSum({ 1: '', 2: 'abc' }, new Set([1, 2]))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payersBalanced', () => {
|
||||
it('is true when the payer amounts add up to the total', () => {
|
||||
expect(payersBalanced({ 1: '45', 2: '45' }, new Set([1, 2]), 90)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when they do not', () => {
|
||||
expect(payersBalanced({ 1: '45', 2: '40' }, new Set([1, 2]), 90)).toBe(false)
|
||||
})
|
||||
|
||||
it('compares to the cent, tolerating float dust', () => {
|
||||
expect(payersBalanced({ 1: '33.34', 2: '33.34', 3: '33.33' }, new Set([1, 2, 3]), 100.01)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rebalancePayers', () => {
|
||||
it('spreads the total across payers when none are pinned', () => {
|
||||
const next = rebalancePayers({}, new Set(), new Set([1, 2]), 90)
|
||||
expect(next).toEqual({ 1: '45.00', 2: '45.00' })
|
||||
})
|
||||
|
||||
it('leaves pinned payers alone and lets the rest absorb the remainder', () => {
|
||||
// Alice pinned at 70 of a 100 bill → Bob must absorb 30.
|
||||
const next = rebalancePayers({ 1: '70' }, new Set([1]), new Set([1, 2]), 100)
|
||||
expect(next[1]).toBe('70')
|
||||
expect(next[2]).toBe('30.00')
|
||||
})
|
||||
|
||||
it('returns the amounts untouched when every payer is pinned', () => {
|
||||
const amounts = { 1: '70', 2: '20' }
|
||||
const next = rebalancePayers(amounts, new Set([1, 2]), new Set([1, 2]), 100)
|
||||
expect(next).toEqual(amounts)
|
||||
})
|
||||
|
||||
it('blanks a free payer whose share works out to zero', () => {
|
||||
// Alice pinned at the full total → Bob is a payer with nothing left to pay.
|
||||
const next = rebalancePayers({ 1: '100' }, new Set([1]), new Set([1, 2]), 100)
|
||||
expect(next[2]).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the result balanced after rebalancing', () => {
|
||||
const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100)
|
||||
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Pure payer math for the Costs expense modal.
|
||||
*
|
||||
* An expense's payers must always sum to its total. The server re-derives
|
||||
* budget_items.total_price from the payer sum (budgetService.createItem), so an
|
||||
* unbalanced payer list would silently rewrite the expense total — and in custom
|
||||
* split mode the member debits, balanced against the old total, would stop
|
||||
* cancelling the payer credits. rebalancePayers keeps the payers the user hasn't
|
||||
* touched absorbing the remainder as they type; payersBalanced gates the save.
|
||||
*
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
|
||||
export function splitCents(amount: number, n: number): number[] {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const base = Math.floor(cents / n)
|
||||
const rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
|
||||
/** Sum the amounts of the selected payers. */
|
||||
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
||||
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
}
|
||||
|
||||
/** True when the payer amounts add up to the expense total, to the cent. */
|
||||
export function payersBalanced(amounts: Record<number, string>, ids: Set<number>, total: number): boolean {
|
||||
return Math.round(payerSum(amounts, ids) * 100) === Math.round(total * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the payers the user has not explicitly edited (everyone not in
|
||||
* `pinned`) so the whole list sums to `total`. Pinned amounts are left as typed.
|
||||
*/
|
||||
export function rebalancePayers(
|
||||
amounts: Record<number, string>,
|
||||
pinned: Set<number>,
|
||||
ids: Set<number>,
|
||||
total: number,
|
||||
): Record<number, string> {
|
||||
const all = [...ids]
|
||||
const free = all.filter(id => !pinned.has(id))
|
||||
if (free.length === 0) return amounts
|
||||
const pinnedSum = all
|
||||
.filter(id => pinned.has(id))
|
||||
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
const shares = splitCents(total - pinnedSum, free.length)
|
||||
const next = { ...amounts }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
|
||||
return next
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import CostsPanel from './CostsPanel'
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
||||
})
|
||||
|
||||
describe('CostsPanel — settlements in the ledger', () => {
|
||||
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// The expense and the settlement (payment) both appear in the unified ledger.
|
||||
await screen.findByText('Dinner')
|
||||
await screen.findByText('Payment')
|
||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a manual payment via the Add payment button', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
// The footer submit is the second "Add payment" control once the modal is open.
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
const submit = addButtons[addButtons.length - 1]
|
||||
await user.click(submit)
|
||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
||||
})
|
||||
|
||||
it('hides payment rows while a text search is active', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
||||
// Payment rows have no name, so a search hides them while the matching expense stays.
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports custom split amounts on save', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total = 100
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Custom/i }))
|
||||
|
||||
const customInputs = screen.getAllByPlaceholderText('50.00')
|
||||
await user.type(customInputs[0], '30')
|
||||
await user.type(customInputs[1], '70')
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.payers).toEqual([
|
||||
expect.objectContaining({ amount: 100 })
|
||||
])
|
||||
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(39.99)
|
||||
})
|
||||
|
||||
it('marks an expense with no payer as Unfinished', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
await screen.findByText('Hotel')
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sums only unfinished expenses in the Outstanding amount card', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Footer only shows the count once unfinished expenses have loaded.
|
||||
const foot = await screen.findByText('expenses need a payer')
|
||||
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
|
||||
|
||||
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
|
||||
// The participant toggles are buttons; the same names also appear as plain text in
|
||||
// the Balances sidebar, so target the buttons specifically.
|
||||
await user.click(screen.getByRole('button', { name: /alice/i }))
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||
expect(submit).not.toBeDisabled()
|
||||
await user.click(submit)
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(120)
|
||||
expect(posted!.member_ids).toEqual([])
|
||||
expect(posted!.payers).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps "no one paid yet" when reopening a payer-less expense (#1533)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let put: Record<string, unknown> | null = null
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }),
|
||||
id: 5,
|
||||
total_price: 120,
|
||||
payers: [],
|
||||
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.put('/api/trips/1/budget/5', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Nobody paid this expense — reopening it must not silently reselect "You".
|
||||
expect(await screen.findByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
||||
|
||||
// …and saving an untouched edit must not assign the current user as payer.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.payers).toEqual([])
|
||||
})
|
||||
|
||||
it('still defaults a brand-new expense to "You" as the payer', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Multi-payer (#1426 regression) ─────────────────────────────────────────
|
||||
// 3.2.0 collapsed payers[] to a single payer, so a bill fronted by two people
|
||||
// credited all of it to one and skewed settle-up. The ledger always supported N
|
||||
// payers; only the form could no longer send them.
|
||||
|
||||
it('records an expense paid by two people with their own amounts', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 11 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
|
||||
// Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45.
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value))
|
||||
.toEqual(['45.00', '45.00'])
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(90)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
{ user_id: 1, amount: 45 },
|
||||
{ user_id: 2, amount: 45 },
|
||||
]))
|
||||
expect(posted!.payers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('blocks saving when the payer amounts do not add up to the total', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: 'Dinner' }) })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
|
||||
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
await user.click(screen.getAllByTestId('payer-toggle')[1])
|
||||
|
||||
// Pin both payers at 20 of a 90 bill, so nobody is left to absorb the rest.
|
||||
const amounts = () => screen.getAllByTestId('payer-amount') as HTMLInputElement[]
|
||||
await user.clear(amounts()[0])
|
||||
await user.type(amounts()[0], '20')
|
||||
await user.clear(amounts()[1])
|
||||
await user.type(amounts()[1], '20')
|
||||
|
||||
// An unbalanced payer list would make the server re-derive total_price as 40.
|
||||
expect(screen.getByText(/must add up to/i)).toBeInTheDocument()
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
expect(addBtns[addBtns.length - 1]).toBeDisabled()
|
||||
expect(posted).toBeNull()
|
||||
})
|
||||
|
||||
it('reopens a two-payer expense with both payers intact', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let put: Record<string, unknown> | null = null
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
|
||||
id: 7,
|
||||
total_price: 90,
|
||||
payers: [{ user_id: 1, amount: 45, username: 'alice' }, { user_id: 2, amount: 45, username: 'bob' }],
|
||||
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.put('/api/trips/1/budget/7', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
const amounts = await screen.findAllByTestId('payer-amount')
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.payers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('exports the expenses as a CSV download (#1500)', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
let exported: Blob | null = null
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner; tapas' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner; tapas')
|
||||
await user.click(screen.getByTitle('Export CSV'))
|
||||
|
||||
expect(exported).toBeTruthy()
|
||||
const text = await exported!.text()
|
||||
expect(text).toContain('Date;Name;Category;Amount;Currency;Amount (EUR);Note')
|
||||
expect(text).toContain('"Dinner; tapas"') // separator inside the name gets quoted
|
||||
expect(text).toContain('Food & drink') // category label, not the raw key
|
||||
expect(text).toContain('90.00;EUR')
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('supports itemized receipt ticket manual entry and split assignment', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 10 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||
|
||||
const addBtn = screen.getByRole('button', { name: /Add item/i })
|
||||
await user.click(addBtn)
|
||||
await user.click(addBtn)
|
||||
await user.click(addBtn)
|
||||
|
||||
const itemNames = screen.getAllByPlaceholderText('Item name')
|
||||
const itemPrices = screen.getAllByPlaceholderText('0.00')
|
||||
|
||||
await user.type(itemNames[0], 'Apples')
|
||||
await user.type(itemPrices[1], '10')
|
||||
|
||||
await user.type(itemNames[1], 'chocolate cake')
|
||||
await user.type(itemPrices[2], '50')
|
||||
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
|
||||
await user.click(bobButtons[1])
|
||||
|
||||
await user.type(itemNames[2], 'Milk')
|
||||
await user.type(itemPrices[3], '40')
|
||||
|
||||
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
|
||||
|
||||
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 75 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 25 }),
|
||||
]))
|
||||
expect(posted!.note).toContain('TICKETJSON:')
|
||||
})
|
||||
|
||||
// ── Display currency ───────────────────────────────────────────────────────
|
||||
|
||||
it('shows amounts in the trip currency when the user has no display currency set', async () => {
|
||||
// No personal preference → the trip's own currency wins, instead of a hardcoded one.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'JPY' }) })
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Sushi' }), total_price: 3000, currency: 'JPY', payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Sushi')
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')
|
||||
// Yen, unconverted and with JPY's zero decimals — not a euro/dollar default.
|
||||
expect(card).toHaveTextContent('¥3,000')
|
||||
})
|
||||
|
||||
// ── Payment currency ───────────────────────────────────────────────────────
|
||||
// A transfer settling a shared bill can be made in any currency, so it carries its
|
||||
// own rather than being assumed to be in the display one.
|
||||
|
||||
it('records a payment in the display currency by default', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(addButtons[addButtons.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'EUR' }))
|
||||
})
|
||||
|
||||
it('records a payment made in another currency', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
// Bob paid me back in dollars — the server freezes the USD rate on write.
|
||||
await user.click(screen.getByText(/^EUR/))
|
||||
await user.click(await screen.findByText(/^USD/))
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(addButtons[addButtons.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'USD' }))
|
||||
})
|
||||
|
||||
it('reopens a foreign-currency payment with its own currency', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, currency: 'USD', exchange_rate: 1.1, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// The stored USD amount comes back as-is, not silently reread as euros.
|
||||
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30')
|
||||
expect(screen.getByText(/^USD/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, hasCustomMemberSplit } from './BudgetPanel.helpers'
|
||||
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
@@ -167,11 +167,9 @@ export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped.get(cat) || [])) {
|
||||
// A custom (uneven) split has no single per-person figure, so leave those columns blank (#1458).
|
||||
const customSplit = hasCustomMemberSplit(item)
|
||||
const pp = customSplit ? null : calcPP(item.total_price, item.persons)
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, 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 ?? '',
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('CollabChat', () => {
|
||||
let foundBigEmoji = false;
|
||||
while (el) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr && styleAttr.includes('font-size: calc(40px')) {
|
||||
if (styleAttr && styleAttr.includes('font-size: 40px')) {
|
||||
foundBigEmoji = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
||||
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit',
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
|
||||
<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: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
|
||||
@@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,8 @@ export function ChatMessages(props: any) {
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
@@ -25,7 +25,7 @@ export function ChatMessages(props: any) {
|
||||
{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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
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',
|
||||
}}>
|
||||
@@ -51,13 +51,13 @@ export function ChatMessages(props: any) {
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
<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>
|
||||
@@ -76,7 +76,7 @@ export function ChatMessages(props: any) {
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
@@ -103,7 +103,7 @@ export function ChatMessages(props: any) {
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ export function ChatMessages(props: any) {
|
||||
<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: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
@@ -138,7 +138,7 @@ export function ChatMessages(props: any) {
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
@@ -146,16 +146,16 @@ export function ChatMessages(props: any) {
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
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: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
|
||||
<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' }}>
|
||||
@@ -233,7 +233,7 @@ export function ChatMessages(props: any) {
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -34,14 +34,14 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
{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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
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}
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
|
||||
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/notes', () =>
|
||||
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Remove Me');
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
// Deleting now asks for confirmation first — the note stays until confirmed.
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove Me')).toBeInTheDocument();
|
||||
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
|
||||
const deleteBtn = screen.getByTitle('Delete');
|
||||
await user.click(deleteBtn);
|
||||
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import type { User } from '../../types'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState(null)
|
||||
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
|
||||
|
||||
// Empty categories (no notes yet) stored in localStorage
|
||||
const [emptyCategories, setEmptyCategories] = useState(() => {
|
||||
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
|
||||
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
|
||||
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
|
||||
pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +240,7 @@ function CollabNotesLoading({ t }: NotesState) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -263,7 +260,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
|
||||
}}>
|
||||
<StickyNote size={14} color="var(--text-faint)" />
|
||||
@@ -277,7 +274,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
<Settings size={14} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>}
|
||||
@@ -292,7 +289,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === null ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
@@ -306,7 +303,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
|
||||
function CollabNotesGrid(S: NotesState) {
|
||||
const {
|
||||
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
|
||||
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
|
||||
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
|
||||
} = S
|
||||
return (
|
||||
@@ -334,10 +331,10 @@ function CollabNotesGrid(S: NotesState) {
|
||||
padding: '48px 20px', textAlign: 'center', height: '100%',
|
||||
}}>
|
||||
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
|
||||
currentUser={currentUser}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleUpdateNote}
|
||||
onDelete={setPendingDeleteNoteId}
|
||||
onDelete={handleDeleteNote}
|
||||
onEdit={setEditingNote}
|
||||
onView={setViewingNote}
|
||||
onPreviewFile={setPreviewFile}
|
||||
@@ -397,10 +394,10 @@ function ViewNoteModal(S: NotesState) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||
{viewingNote.category && (
|
||||
<span style={{
|
||||
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
|
||||
color: getCategoryColor(viewingNote.category),
|
||||
background: `${getCategoryColor(viewingNote.category)}18`,
|
||||
padding: '2px 8px', borderRadius: 6,
|
||||
@@ -422,11 +419,11 @@ function ViewNoteModal(S: NotesState) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(viewingNote.attachments || []).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
@@ -449,10 +446,10 @@ function ViewNoteModal(S: NotesState) {
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; 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: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
viewingNote, showNewModal, editingNote, previewFile, showSettings,
|
||||
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
|
||||
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
|
||||
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
} = S
|
||||
|
||||
if (loading) return <CollabNotesLoading {...S} />
|
||||
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm: delete a collab note — guards against accidental deletion */}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDeleteNoteId !== null}
|
||||
onClose={() => setPendingDeleteNoteId(null)}
|
||||
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
|
||||
title={t('collab.notes.confirmDeleteTitle')}
|
||||
message={t('collab.notes.confirmDeleteBody')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -17,7 +16,7 @@ interface NoteCardProps {
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => void
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
onView: (note: CollabNote) => void
|
||||
onPreviewFile: (file: NoteFile) => void
|
||||
@@ -29,7 +28,7 @@ interface NoteCardProps {
|
||||
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 || avatarSrc(note.avatar) }
|
||||
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(() => {
|
||||
@@ -64,11 +63,11 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
}}>
|
||||
{!!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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.category && (
|
||||
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
<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>
|
||||
)}
|
||||
@@ -116,7 +115,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
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}
|
||||
@@ -138,7 +137,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.content && (
|
||||
<div className="collab-note-md" style={{
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
@@ -152,14 +151,14 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<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: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||
<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/')
|
||||
@@ -180,12 +179,12 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
}}
|
||||
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: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<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: 'calc(8px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
}} 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: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
<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' }}>
|
||||
@@ -82,7 +82,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
{/* Categories list */}
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{allCats.length === 0 && (
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
)}
|
||||
@@ -119,7 +119,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
style={{
|
||||
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
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)',
|
||||
@@ -133,7 +133,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
{/* Save */}
|
||||
<button onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
{t('collab.notes.save')}
|
||||
</button>
|
||||
|
||||
@@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
: <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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<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>
|
||||
@@ -48,21 +48,21 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
/* 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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<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: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
|
||||
<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: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
|
||||
<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>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
@@ -153,7 +153,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
@@ -173,7 +173,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
@@ -186,7 +186,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
@@ -205,7 +205,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
@@ -220,7 +220,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
|
||||
{/* Category pills */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||
<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' }}>
|
||||
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||
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>
|
||||
)
|
||||
@@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
<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 = '' }} />
|
||||
@@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
<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' }}>
|
||||
@@ -269,7 +269,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
})}
|
||||
{/* 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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
<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} />
|
||||
@@ -277,7 +277,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
</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: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
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>
|
||||
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
padding: '7px 14px',
|
||||
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontFamily: FONT,
|
||||
border: 'none',
|
||||
|
||||
@@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} color="var(--text-muted)" />
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
{domain}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
|
||||
@@ -88,30 +88,30 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}>
|
||||
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
|
||||
<Plus size={12} /> {t('collab.polls.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,13 +126,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={!canSubmit} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{submitting ? '...' : t('collab.polls.create')}
|
||||
</button>
|
||||
@@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
@@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{voter.username}
|
||||
@@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Lock size={8} /> {t('collab.polls.closed')}
|
||||
</span>
|
||||
)}
|
||||
{remaining && !isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multi_choice && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', 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')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
|
||||
{total} {total === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
@@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
|
||||
}}>
|
||||
{pct}%
|
||||
@@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
@@ -463,8 +463,8 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{polls.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
|
||||
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -474,7 +474,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -92,7 +91,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -102,8 +101,8 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{upcoming.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
@@ -115,7 +114,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)',
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
@@ -133,15 +132,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
>
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
@@ -153,13 +152,13 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
@@ -176,15 +175,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionLink, CollectionStatus } from '@trek/shared'
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
|
||||
|
||||
interface AddPlaceToCollectionModalProps {
|
||||
isOpen: boolean
|
||||
collectionId: number
|
||||
collectionName: string
|
||||
categories: Category[]
|
||||
onClose: () => void
|
||||
onAdded: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a place to the current list — everything in one view: a search field that
|
||||
* fills in the location when a result is picked, plus name / category / status /
|
||||
* markdown description / links, all editable together before saving. Stays open
|
||||
* after each add so several places can be added in a row.
|
||||
*/
|
||||
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MapsPlace[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await mapsApi.search(query, language)
|
||||
setResults((res.places as MapsPlace[]) || [])
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) }
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
const cleanName = name.trim()
|
||||
if (!cleanName) return
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: collectionId,
|
||||
name: cleanName,
|
||||
address: (picked && str(picked.address)) ?? null,
|
||||
lat: (picked && num(picked.lat)) ?? null,
|
||||
lng: (picked && num(picked.lng)) ?? null,
|
||||
google_place_id: (picked && str(picked.google_place_id)) ?? null,
|
||||
google_ftid: (picked && str(picked.google_ftid)) ?? null,
|
||||
osm_id: (picked && str(picked.osm_id)) ?? null,
|
||||
website: (picked && str(picked.website)) ?? null,
|
||||
phone: (picked && str(picked.phone)) ?? null,
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
links: cleanLinks,
|
||||
status,
|
||||
force: true,
|
||||
})
|
||||
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
|
||||
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
|
||||
reset()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const address = picked ? str(picked.address) : undefined
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('collections.addPlace')}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving || !name.trim()} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search — picking a result fills the location below */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
|
||||
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
|
||||
{t('common.search')}
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
|
||||
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
|
||||
</div>
|
||||
{results.map((r, i) => (
|
||||
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
|
||||
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
|
||||
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
const on = status === s
|
||||
return (
|
||||
<button key={s} type="button" onClick={() => setStatus(s)} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-colors ${on ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
<Icon size={13} style={{ color: on ? undefined : STATUS_META[s].color }} /> {t(STATUS_META[s].labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
{t('collections.noCategory')}
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
const col = cat.color || '#6366f1'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
|
||||
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
|
||||
>
|
||||
<Icon size={13} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
|
||||
{description.trim() && (
|
||||
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
|
||||
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface BulkAssignLabelModalProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
/** Number of selected places the labels will be added to. */
|
||||
count: number
|
||||
onAssign: (labelIds: number[]) => Promise<void> | void
|
||||
/** Open the label manager to create labels first. */
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one or more of the list's labels to add to every selected place. Additive
|
||||
* — it never removes labels a place already has. When the list has no labels yet,
|
||||
* it points the user at the label manager instead.
|
||||
*/
|
||||
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
|
||||
|
||||
const assign = async () => {
|
||||
if (picked.length === 0 || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAssign(picked)
|
||||
setPicked([])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
|
||||
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => {
|
||||
const on = picked.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggle(l.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
|
||||
{on && <Check size={15} className="text-accent shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// FE-COMP-COLFILTERBAR-001 to FE-COMP-COLFILTERBAR-008
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel';
|
||||
import type { StatusFilter } from '../../store/collectionStore';
|
||||
import CollectionFilterBar from './CollectionFilterBar';
|
||||
|
||||
// The component takes `t` as a prop; pull the REAL translation fn from context so
|
||||
// visible labels are actual English strings (All / Idea / Want to go / Visited / Select).
|
||||
type HarnessProps = Omit<React.ComponentProps<typeof CollectionFilterBar>, 't'>;
|
||||
|
||||
function Harness(props: HarnessProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionFilterBar {...props} t={t} />;
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
||||
{ id: 1, name: 'Food', color: '#f00', icon: null, count: 2 },
|
||||
];
|
||||
|
||||
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
return {
|
||||
statusFilter: 'all' as StatusFilter,
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Both dropdown triggers currently read "All" (status=all, category=all).
|
||||
// With a category present there are exactly two "All" triggers: status + category.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness {...makeProps()} />);
|
||||
|
||||
// First "All" trigger is the status dropdown (rendered before the category one).
|
||||
const statusTrigger = screen.getAllByRole('button', { name: 'All' })[0];
|
||||
expect(statusTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(statusTrigger);
|
||||
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(within(listbox).getByRole('option', { name: /Idea/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Want to go/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Visited/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-003: clicking a status option calls onStatusFilter with that status', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStatusFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onStatusFilter })} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[0]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Want to go/i }));
|
||||
|
||||
expect(onStatusFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onStatusFilter).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Two dropdown triggers = status + category.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// Only the status dropdown remains.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCategoryFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onCategoryFilter })} />);
|
||||
|
||||
// Second "All" trigger is the category dropdown.
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[1]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Food/i }));
|
||||
|
||||
expect(onCategoryFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onCategoryFilter).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-007: clicking the Select button calls onToggleSelect', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSelect = vi.fn();
|
||||
render(<Harness {...makeProps({ onToggleSelect })} />);
|
||||
|
||||
const selectBtn = screen.getByRole('button', { name: 'Select' });
|
||||
expect(selectBtn).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(selectBtn);
|
||||
|
||||
expect(onToggleSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-008: showSelect=false hides the Select button', () => {
|
||||
render(<Harness {...makeProps({ showSelect: false })} />);
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Small custom dropdown — compact trigger + click-away popover. */
|
||||
function Dropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
onSelect: (key: string | number) => void
|
||||
lead: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
|
||||
}, [open])
|
||||
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="col-filter" ref={ref}>
|
||||
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
|
||||
{cur.icon ?? lead}
|
||||
<span className="col-filter-lbl">{cur.label}</span>
|
||||
<ChevronDown size={14} className="col-filter-chev" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="col-filter-pop" role="listbox">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={o.key === current}
|
||||
className={`col-filter-opt${o.key === current ? ' on' : ''}`}
|
||||
onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
>
|
||||
{o.icon ?? <span className="col-filter-dot ghost" />}
|
||||
<span className="col-filter-lbl">{o.label}</span>
|
||||
{o.count != null && <span className="col-filter-count">{o.count}</span>}
|
||||
{o.key === current && <Check size={13} className="col-filter-check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CollectionFilterBarProps {
|
||||
statusFilter: StatusFilter
|
||||
counts: Record<StatusFilter, number>
|
||||
categoryFilter: number | 'all'
|
||||
categoryOptions: CategoryOption[]
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels: boolean
|
||||
onManageLabels: () => void
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter row above the places — a status dropdown (All / Idea / Want / Visited
|
||||
* with counts) and, when the list has categorised places, a category dropdown.
|
||||
* Custom compact dropdowns so they barely take any space.
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all'), count: counts.all },
|
||||
...STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} />, count: counts[s] }
|
||||
}),
|
||||
]
|
||||
|
||||
const catTotal = categoryOptions.reduce((n, c) => n + c.count, 0)
|
||||
const catOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all'), count: catTotal },
|
||||
...categoryOptions.map(c => {
|
||||
const Icon = getCategoryIcon(c.icon ?? undefined)
|
||||
return { key: c.id, label: c.name, icon: <Icon size={13} style={{ color: c.color ?? undefined }} />, count: c.count }
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="col-filterbar">
|
||||
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
|
||||
{categoryOptions.length > 0 && (
|
||||
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
|
||||
)}
|
||||
{showSelect && (
|
||||
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</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