mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-07 21:16:44 +00:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b010b99aa | |||
| 6ae47cbdf5 | |||
| 91217e83fa | |||
| d110f8f0ce | |||
| 8b801af94a | |||
| b16a7d7b77 | |||
| 64136484ee | |||
| 16e27926c5 | |||
| cf9be554ac | |||
| 39f27ef55d | |||
| 3d714ff04c | |||
| c3c70bcf9d | |||
| bc56c2dcc1 | |||
| aec9c4f97c | |||
| a4c0a79956 | |||
| 12745cd03c | |||
| 3c507c2314 | |||
| aad99e1c30 | |||
| 2a88b0c711 | |||
| 73e9ccfd6d | |||
| ef5f8b9d38 | |||
| 332e5c3c42 | |||
| 45ef63cbc1 | |||
| 3fdc61c8fa | |||
| 1127700219 | |||
| 17f3c86967 | |||
| 8c9476e487 | |||
| 447cb31e7f | |||
| 29c2979da4 | |||
| 5d52d089db | |||
| 7dabfa3779 | |||
| 7cd5a5d04c | |||
| a7dc4fbfe7 | |||
| a920c6f72a | |||
| 1cee4e93b1 | |||
| 660e4b9dfc | |||
| af73943be8 | |||
| f61e144e11 | |||
| f7c54d3d90 | |||
| 02c52e63a0 | |||
| de49cde363 | |||
| 5e3f7095a5 | |||
| 380641f4fd | |||
| 452360683c | |||
| 132ddcad16 | |||
| 2b0815be9f | |||
| f4dbe02702 | |||
| 8ffdb9e153 | |||
| 440bb8497f | |||
| fab288a889 | |||
| 5b9723cf8d | |||
| 9a823c0bc2 | |||
| c1eaa91e50 | |||
| c7998e926a | |||
| 04415be3bc | |||
| 06b94c14a5 | |||
| 5a007a1f0e | |||
| 3c87d37cd9 | |||
| f2304e076b | |||
| 216d5e99e1 | |||
| 45f1ff7799 | |||
| 268ed419b2 | |||
| bd2d91845b | |||
| 8eb5e19fea | |||
| 5f77fa5cca | |||
| bfc857d246 | |||
| 05269f0710 | |||
| 5152a5849f | |||
| 1fdc19712f | |||
| a83ecc4ffb | |||
| 60cbd20327 | |||
| 3686792ce8 | |||
| 2af292bd64 | |||
| 33fcbba414 | |||
| 3011cf6c2f | |||
| 7d87d87eec | |||
| 8014264b21 | |||
| baf156a36b | |||
| 59134ae38e | |||
| 6cdcc82d9c | |||
| 1e0feb93dd | |||
| f2972481fe | |||
| b9da2494d4 | |||
| fd5258129a | |||
| 56fee19d64 | |||
| 6be8a67c82 | |||
| 3198fd35eb | |||
| ef3f043e00 | |||
| 6a3e6ae083 | |||
| b8ceb081fa | |||
| 0c484959ce | |||
| f97e284592 | |||
| 0910c90e7f | |||
| de4077d461 | |||
| 68e101ed37 | |||
| 25b8d28574 | |||
| 74cace7c8f | |||
| 4e478722c3 | |||
| 5f9da5b72f | |||
| 783332cc0e | |||
| ec8509f2de | |||
| 37ef3a9d18 | |||
| a54503f26d | |||
| 4abb38b517 | |||
| 743b724cbc | |||
| 04f2ec72c6 | |||
| 7673aa52f2 | |||
| e56a901d82 | |||
| 641711322e | |||
| fac2393388 | |||
| 7eac5a5a02 | |||
| d6ed7a60a0 | |||
| ad64df42ed | |||
| 4af35b162e | |||
| 20c1858b23 | |||
| e986c9ab27 | |||
| 61ffdb553e | |||
| 1abc9b2bc7 | |||
| 8713443665 | |||
| c92c02e1b8 | |||
| 993d9bf713 | |||
| c7e4b2781b | |||
| a88cd772cf | |||
| 98d11d4267 | |||
| 6707dac4a9 | |||
| c552472b63 | |||
| 5fd66f4833 | |||
| 50609b078a | |||
| 42b45dcd82 | |||
| 9dd9057b7b | |||
| 23987c76bb | |||
| 7173e82fe8 | |||
| 72dfa2c60c | |||
| d19305bda4 | |||
| 7aa2f6e4f2 | |||
| 3e64cb86a6 | |||
| e4efcf0840 | |||
| e34f40b686 | |||
| 3701ab6cad | |||
| e91f592f22 | |||
| 1cc69fc22a | |||
| 4d131db9af | |||
| f5d03e7213 | |||
| 891171ce6c | |||
| 720edce2ee | |||
| b27793f99a | |||
| 813db0ca6e | |||
| 741639edf0 | |||
| bb8f4d4e5e | |||
| fac043c691 | |||
| a3f395e5ac | |||
| b6a414b79f | |||
| 200108b76a | |||
| a7334a9060 | |||
| 2cda779bc5 | |||
| 4742915389 | |||
| d6bba454e0 | |||
| 6f42e84183 | |||
| cb3f9f0021 | |||
| f24d44b4a3 | |||
| af90ba0911 | |||
| 8c941b52f9 | |||
| c7e8a5614d | |||
| c10b9cc202 | |||
| d1e024277f | |||
| 172cff57a2 | |||
| 0d6737726d | |||
| 6996a67670 | |||
| 84adc28684 | |||
| f206fa6dff | |||
| c3b3c278b8 | |||
| d09a62fcc8 | |||
| f4b2143a59 | |||
| 33f554b1bf | |||
| fc1f29bb29 | |||
| 01e5859564 | |||
| 6a70f4fc41 | |||
| 27fbc241e8 | |||
| 574c54c16c | |||
| 0cb0567d28 | |||
| 76447f4a73 | |||
| 55ff5c03dd | |||
| 3277965426 | |||
| d95d26e493 | |||
| 4abe96fe01 | |||
| 7bac753ff3 | |||
| 743397994e | |||
| 459426ed43 | |||
| b3fa87bdd6 | |||
| 519dc3b0d8 | |||
| c1d61c98f0 | |||
| c7f5694f63 | |||
| d0b4052c5d | |||
| 1c81e8b959 | |||
| 8f1c99a07a | |||
| 5fdd4aa153 | |||
| 22801938b5 | |||
| 8640100312 | |||
| e666313865 | |||
| aa72d527c9 | |||
| 684ac3b442 | |||
| f049229e25 | |||
| 38565c3c6d | |||
| a1cbc11169 | |||
| b859ae8b00 | |||
| ae14a6c860 | |||
| 41c541828f | |||
| 37f1fff367 | |||
| 0c1c534435 |
@@ -32,5 +32,6 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
**/*.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 }}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
@@ -22,22 +32,15 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
@@ -109,7 +112,7 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
@@ -214,4 +217,3 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
charts_url: https://chart.liketrek.com
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -14,7 +14,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -66,3 +66,5 @@ test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -71,10 +71,6 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# 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
|
||||
|
||||
@@ -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://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/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"><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>
|
||||
|
||||
@@ -133,7 +133,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), 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, approved school holiday overlays, carry-over tracking
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **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
|
||||
@@ -275,12 +275,12 @@ docker compose up -d
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
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>
|
||||
|
||||
@@ -331,8 +331,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 +368,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,7 +403,6 @@ 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` |
|
||||
| `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` |
|
||||
@@ -444,9 +428,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>
|
||||
|
||||
@@ -472,3 +455,4 @@ 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.
|
||||
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<?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>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
|
||||
+1
-3
@@ -15,13 +15,11 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
|
||||
|
||||
## Usage
|
||||
|
||||
Or install directly from the local chart:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.4.1
|
||||
version: 3.1.3
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.4.1"
|
||||
appVersion: "3.1.3"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -4,11 +4,6 @@ image:
|
||||
# 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.
|
||||
@@ -103,12 +92,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
|
||||
|
||||
@@ -3,6 +3,3 @@ e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<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>
|
||||
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.4.1",
|
||||
"version": "3.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,8 +20,6 @@
|
||||
"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\""
|
||||
@@ -36,7 +34,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
+8
-11
@@ -27,10 +27,8 @@ import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
||||
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import MobileShell from './mobile/MobileShell'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
@@ -55,7 +53,6 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -90,11 +87,12 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
|
||||
// sheets, toasts); from 768px up the legacy wrapper stays untouched. The
|
||||
// shell branches internally so pages keep their state when the viewport
|
||||
// crosses the breakpoint.
|
||||
return <MobileShell isPhone={isPhone}>{children}</MobileShell>
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
@@ -202,7 +200,6 @@ export default function App() {
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
@@ -213,7 +210,7 @@ export default function App() {
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!isAuthPage && <SaveToCollectionModal />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
+45
-276
@@ -1,6 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
@@ -27,12 +26,12 @@ import {
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
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,
|
||||
@@ -240,40 +239,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),
|
||||
@@ -288,7 +253,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),
|
||||
@@ -369,7 +334,7 @@ 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),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
@@ -388,8 +353,6 @@ export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
||||
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).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),
|
||||
}
|
||||
@@ -401,29 +364,20 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
uploadImage: (tripId: number | string, id: number | string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
|
||||
},
|
||||
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
|
||||
rating === null
|
||||
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
|
||||
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
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),
|
||||
@@ -445,8 +399,6 @@ export const assignmentsApi = {
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
|
||||
updateTransport: (tripId: number | string, id: number, mode: string | null) => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, { transport_mode: mode } satisfies AssignmentTransportRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
@@ -463,7 +415,7 @@ export const packingApi = {
|
||||
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),
|
||||
@@ -509,31 +461,14 @@ export const adminApi = {
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
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),
|
||||
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
// 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.
|
||||
@@ -618,191 +553,12 @@ 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'
|
||||
}
|
||||
|
||||
/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized:
|
||||
* coordinates range-checked, vertex budget capped, styling clamped to the tone
|
||||
* palette + bounded numerics — never free-form CSS or markup. */
|
||||
export interface PluginMapLayerFeature {
|
||||
type: 'polyline' | 'polygon' | 'circle';
|
||||
points?: Array<[number, number]>;
|
||||
center?: [number, number];
|
||||
radiusM?: number;
|
||||
tone: 'default' | 'success' | 'warn' | 'danger';
|
||||
width: number;
|
||||
dash: 'solid' | 'dash' | 'dot';
|
||||
opacity: number;
|
||||
fill: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */
|
||||
export interface PluginMapLayer {
|
||||
pluginId: string; id: string; name?: string;
|
||||
features: PluginMapLayerFeature[];
|
||||
}
|
||||
|
||||
/** A time contribution a dayScheduleProvider plugin attaches to the day plan
|
||||
* ("35 min charging at this stop"). Server-normalized: dayIds checked against
|
||||
* the trip, minutes clamped to a day, labels sanitized + capped. */
|
||||
export interface PluginDayScheduleItem {
|
||||
pluginId: string; id: string; dayId: number;
|
||||
assignmentId?: number; reservationId?: number;
|
||||
position?: 'start' | 'end';
|
||||
minutes?: number; label: string;
|
||||
tone: 'default' | 'success' | 'warn' | 'danger';
|
||||
}
|
||||
|
||||
/** A route computed by a routeProvider plugin (server-normalized: coordinates
|
||||
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
|
||||
* or refused — the caller falls back to straight lines like on an OSRM outage. */
|
||||
export interface PluginRouteResult {
|
||||
pluginId: string; profile: string;
|
||||
coordinates: Array<[number, number]>;
|
||||
distance: number; duration: number;
|
||||
legs: Array<{ distance: number; duration: number; note?: string }>;
|
||||
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
|
||||
}
|
||||
|
||||
/** 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[] }),
|
||||
// Vector overlays (polylines/polygons/circles) plugins draw on the trip map via
|
||||
// the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe.
|
||||
mapLayers: (tripId: number | string) =>
|
||||
apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }),
|
||||
// Route the given waypoints through ONE routeProvider plugin profile (targeted,
|
||||
// not a fan-out — the user picked this profile in the route toggle). Slow by
|
||||
// design (external solvers): the server allows the plugin 20 s.
|
||||
pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) =>
|
||||
apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }),
|
||||
// Time contributions plugins attach to the day plan via the dayScheduleProvider
|
||||
// hook (charging stops, security buffers). Host-normalized; fail-safe.
|
||||
daySchedule: (tripId: number | string) =>
|
||||
apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }),
|
||||
// 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)
|
||||
},
|
||||
// Call one of a plugin's own declared routes through the host proxy.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) =>
|
||||
apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
@@ -815,8 +571,8 @@ export const airtrailApi = {
|
||||
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),
|
||||
import: (tripId: number, flightIds: string[]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
@@ -841,12 +597,27 @@ 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),
|
||||
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),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
@@ -857,7 +628,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),
|
||||
@@ -904,8 +675,8 @@ 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),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, 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),
|
||||
@@ -913,7 +684,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),
|
||||
@@ -938,7 +711,7 @@ export const reservationsApi = {
|
||||
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' }, timeout: 0 }).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),
|
||||
@@ -948,7 +721,7 @@ export const reservationsApi = {
|
||||
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)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
|
||||
},
|
||||
// Poll a background job — recovery path when a WebSocket push was missed.
|
||||
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
|
||||
@@ -1011,7 +784,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),
|
||||
@@ -1046,7 +819,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),
|
||||
@@ -1083,10 +856,6 @@ export const notificationsApi = {
|
||||
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,4 +1,4 @@
|
||||
import apiClient, { postMultipart } from './client'
|
||||
import apiClient from './client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type {
|
||||
CollectionListResponse,
|
||||
@@ -18,9 +18,6 @@ import type {
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
@@ -56,7 +53,7 @@ export const collectionsApi = {
|
||||
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),
|
||||
ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data),
|
||||
remove: (id: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
reorder: (orderedIds: number[]): Promise<unknown> =>
|
||||
@@ -70,14 +67,8 @@ export const collectionsApi = {
|
||||
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),
|
||||
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
|
||||
postMultipart(`${base}/places/${pid}/image`, formData),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
|
||||
rating === null
|
||||
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
|
||||
: ax.put(`${base}/places/${pid}/rating`, { rating }).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> =>
|
||||
@@ -104,15 +95,4 @@ export const collectionsApi = {
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ describe('SCOPE_GROUPS', () => {
|
||||
const expected = [
|
||||
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||
'places:read', 'places:write',
|
||||
'collections:read', 'collections:write',
|
||||
'atlas:read', 'atlas:write',
|
||||
'packing:read', 'packing:write',
|
||||
'todos:read', 'todos:write',
|
||||
@@ -17,7 +16,6 @@ describe('SCOPE_GROUPS', () => {
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -34,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(29)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
|
||||
@@ -20,8 +20,6 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
|
||||
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
|
||||
'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' },
|
||||
'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' },
|
||||
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -6,7 +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 { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
@@ -286,7 +286,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
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 }))}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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';
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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';
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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';
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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';
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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,134 +241,137 @@ 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>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<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="space-y-0">
|
||||
{(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">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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]">
|
||||
{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]">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<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"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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="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 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<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]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<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="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="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
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 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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()
|
||||
})
|
||||
})
|
||||
@@ -136,14 +136,7 @@ export default function BackgroundTasksWidget() {
|
||||
{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>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
@@ -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,
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -108,12 +107,12 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByPlaceholderText('0,00') as HTMLInputElement[]
|
||||
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')
|
||||
const customInputs = screen.getAllByPlaceholderText('50.00')
|
||||
await user.type(customInputs[0], '30')
|
||||
await user.type(customInputs[1], '70')
|
||||
|
||||
@@ -146,7 +145,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '39,99') // comma → normalized to 39.99
|
||||
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
|
||||
@@ -165,28 +164,6 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
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(
|
||||
@@ -203,7 +180,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '120') // total only, paid on-site later
|
||||
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
|
||||
@@ -222,194 +199,6 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
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(
|
||||
@@ -435,7 +224,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await user.click(addBtn)
|
||||
|
||||
const itemNames = screen.getAllByPlaceholderText('Item name')
|
||||
const itemPrices = screen.getAllByPlaceholderText('0,00')
|
||||
const itemPrices = screen.getAllByPlaceholderText('0.00')
|
||||
|
||||
await user.type(itemNames[0], 'Apples')
|
||||
await user.type(itemPrices[1], '10')
|
||||
@@ -448,7 +237,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await user.type(itemNames[2], 'Milk')
|
||||
await user.type(itemPrices[3], '40')
|
||||
|
||||
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
|
||||
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
|
||||
|
||||
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||
@@ -465,102 +254,4 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
]))
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -10,18 +10,15 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { payersBalanced, rebalancePayers } from './CostsPanel.helpers'
|
||||
import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length
|
||||
@@ -95,9 +92,6 @@ interface Settlement {
|
||||
from_user_id: number
|
||||
to_user_id: number
|
||||
amount: number
|
||||
// The currency the transfer was entered in. Legacy rows predate it (null) and are
|
||||
// read as the display currency, which is what the server assumes for them too.
|
||||
currency?: string | null
|
||||
created_at?: string
|
||||
from_username?: string
|
||||
to_username?: string
|
||||
@@ -185,9 +179,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -195,9 +186,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
||||
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
return { totalSpend, myPaid, myShare, owe, owed }
|
||||
}, [budgetItems, settlement, me])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
@@ -265,7 +254,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -276,7 +265,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -295,39 +284,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// CSV export of all expenses — the wiki-documented export that got lost in the
|
||||
// Costs rework (#1500). One row per expense, oldest first.
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
|
||||
for (const e of items) {
|
||||
const cur = curOf(e)
|
||||
// Ticket notes carry the itemized-receipt JSON, not a human note.
|
||||
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
|
||||
rows.push([
|
||||
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
|
||||
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
|
||||
baseTotal(e).toFixed(currencyDecimals(base)),
|
||||
esc(note),
|
||||
].join(sep))
|
||||
}
|
||||
|
||||
const bom = ''
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
|
||||
a.download = `costs-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── small presentational helpers ────────────────────────────────────────
|
||||
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
|
||||
const url = personById(id)?.avatar_url
|
||||
@@ -413,7 +369,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
||||
icon={<ArrowDown size={18} />} tone="owe"
|
||||
foot={totals.owe > 0.01
|
||||
@@ -424,11 +380,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
foot={totals.owed > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
||||
<SummaryCard label={t('costs.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
|
||||
icon={<AlertCircle size={18} />} tone="unfinished"
|
||||
foot={totals.outstandingCount > 0
|
||||
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||
icon={<BarChart3 size={18} />} tone="total"
|
||||
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
||||
@@ -458,23 +409,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
className="bg-surface-input border border-edge text-content-muted disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayBanner}
|
||||
{dayGroups.length === 0 ? (
|
||||
search ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{t('costs.noMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState scene="costs" title={t('costs.emptyText')} />
|
||||
)
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
@@ -533,7 +475,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
@@ -569,11 +511,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
||||
.costs-root .exp-actions { opacity: 1; }
|
||||
@media (max-width: 1100px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
@@ -641,18 +580,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outstanding */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: '#d9770622', color: '#d97706', flexShrink: 0 }}><AlertCircle size={17} /></div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
@@ -666,14 +593,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
{/* Expenses */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
className="bg-surface-card border border-edge text-content-muted disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
|
||||
<Search size={16} className="text-content-faint" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} />
|
||||
@@ -725,19 +645,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && unfinished && (
|
||||
{isMobile && isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
|
||||
{unfinished && !isMobile && (
|
||||
{isUnfinished && !isMobile && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
@@ -783,23 +705,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
||||
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
// Legacy transfers carry no currency and were entered in the display base.
|
||||
const cur = (s.currency || base).toUpperCase()
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('costs.payment')}
|
||||
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(convert(s.amount, cur))}</span>}
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(convert(s.amount, cur))}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
@@ -869,9 +786,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
// ── pure subcomponents ─────────────────────────────────────────────────────
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) {
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
||||
const total = tone === 'total'
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : tone === 'unfinished' ? '#d97706' : undefined
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
||||
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
||||
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
||||
// while letting Intl place the symbol and pick separators per locale + currency.
|
||||
@@ -915,14 +832,11 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
)
|
||||
}
|
||||
|
||||
// Add or edit a settle-up payment (from / to / amount / currency). Reachable inline
|
||||
// from the ledger row and from a manual "Add payment" button, so recording "I sent
|
||||
// money to X" works the same whether or not there's an outstanding expense behind it.
|
||||
// A transfer can be made in any currency — paying a rouble debt in euros is normal —
|
||||
// so it carries its own, defaulting to the display currency. The server freezes its
|
||||
// FX rate on write, the same way an expense's is frozen.
|
||||
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||
// X" works the same whether or not there's an outstanding expense behind it.
|
||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -930,7 +844,6 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const amt = parseFloat(amount) || 0
|
||||
@@ -940,7 +853,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
@@ -948,6 +861,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
return (
|
||||
@@ -967,22 +881,10 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
<label className={labelCls}>{t('costs.to')}</label>
|
||||
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{SYMBOLS[cur] || (cur + ' ')}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value.replace(',', '.'))}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.currency')}</label>
|
||||
<CustomSelect value={cur} onChange={v => setCur(String(v))} searchable
|
||||
options={currenciesWith(cur).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none', fontWeight: 600 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1018,29 +920,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
|
||||
// Payer state. An expense can be fronted by several people, each with their own
|
||||
// amount (budget_item_payers) — a shared card, or "I got this round, you get the
|
||||
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
|
||||
|
||||
// Payer state: 0 represents "Nobody (planning entry)"
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0]
|
||||
if (existingPayer) return existingPayer.user_id
|
||||
return editing ? 0 : me
|
||||
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
|
||||
return existingPayer ? existingPayer.user_id : me
|
||||
})
|
||||
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1)
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
// the others absorb the remainder.
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
@@ -1111,8 +995,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
const valid = name.trim().length > 0 && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
@@ -1122,52 +1005,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
setTotal(v.replace(',', '.'))
|
||||
}
|
||||
|
||||
// Keep the payer amounts summing to the total as it changes — including in ticket
|
||||
// mode, where the total is derived from the ticket items rather than typed.
|
||||
useEffect(() => {
|
||||
if (!multiPayer) return
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalNum])
|
||||
|
||||
const enableMultiPayer = () => {
|
||||
const seed = payerIds.size > 0 ? new Set(payerIds) : new Set<number>([payerId > 0 ? payerId : me])
|
||||
const pinned = new Set<number>()
|
||||
setPayerIds(seed)
|
||||
setPinnedPayers(pinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinned, seed, totalNum))
|
||||
setMultiPayer(true)
|
||||
}
|
||||
|
||||
const disableMultiPayer = () => {
|
||||
// Collapsing back keeps the first payer; their amount becomes the whole total.
|
||||
const [first] = [...payerIds]
|
||||
setPayerId(first ?? me)
|
||||
setMultiPayer(false)
|
||||
}
|
||||
|
||||
const togglePayer = (id: number) => {
|
||||
const nextIds = new Set(payerIds)
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
if (nextIds.has(id)) {
|
||||
nextIds.delete(id)
|
||||
nextPinned.delete(id)
|
||||
} else {
|
||||
nextIds.add(id)
|
||||
}
|
||||
setPayerIds(nextIds)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum))
|
||||
}
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.')
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
nextPinned.add(id)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum))
|
||||
}
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
@@ -1232,11 +1069,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
: (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const memberList = [...participants].map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
@@ -1295,8 +1128,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
|
||||
onChange={e => onTotalChange(e.target.value)}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
@@ -1305,7 +1138,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.currency')}</label>
|
||||
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -1341,66 +1174,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
|
||||
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
|
||||
className="text-content-muted"
|
||||
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textDecoration: 'underline' }}>
|
||||
{multiPayer ? t('costs.singlePayer') : t('costs.multiplePayers')}
|
||||
</button>
|
||||
</div>
|
||||
{!multiPayer ? (
|
||||
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
]}
|
||||
style={{ width: '100%' }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map((p, idx) => {
|
||||
const on = payerIds.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge"
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button type="button" onClick={() => togglePayer(p.id)} data-testid="payer-toggle"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>
|
||||
{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}
|
||||
</span>}
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</span>
|
||||
</button>
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => togglePayer(p.id)} className="text-content-faint"
|
||||
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', textAlign: 'right' }}>
|
||||
{t('costs.tapToInclude')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!payersOk && (
|
||||
<div style={{ marginTop: 8, fontSize: 'calc(12.5px * var(--fs-scale-caption, 1))', color: '#d97706' }}>
|
||||
{t('costs.payersUnbalanced', { amount: formatMoney(totalNum, currency, locale) })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
]}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1430,22 +1210,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ticketItems.map((item, itemIdx) => (
|
||||
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 130px auto', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Item name"
|
||||
value={item.name}
|
||||
onChange={e => handleUpdateItemName(item.id, e.target.value)}
|
||||
className="bg-surface-input border border-edge text-content"
|
||||
style={{ minWidth: 0, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
|
||||
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
|
||||
/>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
|
||||
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="decimal"
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
value={localizeAmountInput(item.price, currency)}
|
||||
onValueChange={v => handleUpdateItemPrice(item.id, v)}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={item.price}
|
||||
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
|
||||
/>
|
||||
@@ -1526,7 +1307,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
|
||||
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
|
||||
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -124,13 +124,9 @@ describe('CollabChat', () => {
|
||||
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-009: shows guidance in empty state', async () => {
|
||||
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
// The empty state now renders the shared EmptyState: a chat-scene mascot
|
||||
// plus the single "Start the conversation" title (the separate hint
|
||||
// paragraph was dropped in the mobile rewrite).
|
||||
await screen.findByText('Start the conversation');
|
||||
expect(document.querySelector('svg.trek--chat')).toBeInTheDocument();
|
||||
await screen.findByText(/Share ideas, plans/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-010: chat container renders', () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function ChatMessages(props: any) {
|
||||
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||
@@ -13,7 +12,11 @@ export function ChatMessages(props: any) {
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} />
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
|
||||
@@ -11,7 +11,6 @@ import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { User } from '../../types'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
@@ -330,7 +329,18 @@ function CollabNotesGrid(S: NotesState) {
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{sortedNotes.length === 0 ? (
|
||||
/* ── Empty state ── */
|
||||
<EmptyState scene="notes" title={t('collab.notes.empty')} />
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
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 }}>
|
||||
{t('collab.notes.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Notes grid — 2 columns ── */
|
||||
<div style={{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -462,7 +461,11 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{polls.length === 0 ? (
|
||||
<EmptyState scene="polls" title={t('collab.polls.empty')} />
|
||||
<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>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
|
||||
@@ -3,8 +3,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -101,7 +100,11 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<EmptyState scene="guide" title={t('collab.whatsNext.empty')} />
|
||||
<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>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { NumericInput } from '../shared/NumericInput'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
@@ -45,11 +44,6 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
// Address + coordinates: prefilled from a picked result, but also directly
|
||||
// typeable so a place can be added by GPS without searching (#1435).
|
||||
const [address, setAddress] = useState('')
|
||||
const [lat, setLat] = useState('')
|
||||
const [lng, setLng] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
@@ -57,7 +51,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
@@ -73,31 +67,21 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (r: MapsPlace) => {
|
||||
setPicked(r)
|
||||
setName(str(r.name) ?? '')
|
||||
setAddress(str(r.address) ?? '')
|
||||
const la = num(r.lat); const lo = num(r.lng)
|
||||
setLat(la != null ? String(la) : '')
|
||||
setLng(lo != null ? String(lo) : '')
|
||||
setResults([]); setQuery(str(r.name) ?? query)
|
||||
}
|
||||
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)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: collectionId,
|
||||
name: cleanName,
|
||||
address: address.trim() || null,
|
||||
lat: Number.isFinite(latNum) ? latNum : null,
|
||||
lng: Number.isFinite(lngNum) ? lngNum : null,
|
||||
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,
|
||||
@@ -119,12 +103,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
|
||||
const address = picked ? str(picked.address) : undefined
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -184,16 +163,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{/* Address + coordinates — editable so a place can be added by GPS alone */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('places.formAddress')}</label>
|
||||
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} />
|
||||
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className={coordInputClass} />
|
||||
</div>
|
||||
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -27,20 +27,8 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
ratingFilter: 'all',
|
||||
sortMode: 'default',
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
onRatingFilter: vi.fn(),
|
||||
onSortMode: vi.fn(),
|
||||
canAddPlace: false,
|
||||
onAddPlace: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
@@ -55,9 +43,9 @@ beforeEach(() => {
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Three dropdown triggers read "All" (status=all, category=all, rating=all):
|
||||
// status + category + the #1435 rating filter.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(3);
|
||||
// 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 () => {
|
||||
@@ -90,15 +78,15 @@ describe('CollectionFilterBar', () => {
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Three dropdown triggers = status + category + rating.
|
||||
// Two dropdown triggers = status + category.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(3);
|
||||
expect(triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// The status + rating dropdowns remain (the rating filter always shows).
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
// 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 () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
|
||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||
import { ChevronDown, Check, Layers, Tag, 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'
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
@@ -67,22 +67,8 @@ interface CollectionFilterBarProps {
|
||||
counts: Record<StatusFilter, number>
|
||||
categoryFilter: number | 'all'
|
||||
categoryOptions: CategoryOption[]
|
||||
ratingFilter: number | 'all'
|
||||
sortMode: CollectionSortMode
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
onRatingFilter: (f: number | 'all') => void
|
||||
onSortMode: (m: CollectionSortMode) => void
|
||||
// Add a place to the current list — leads the row when the list is editable.
|
||||
canAddPlace: boolean
|
||||
onAddPlace: () => 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
|
||||
@@ -95,10 +81,7 @@ interface CollectionFilterBarProps {
|
||||
* Custom compact dropdowns so they barely take any space.
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, ratingFilter, sortMode,
|
||||
onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode,
|
||||
canAddPlace, onAddPlace,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
@@ -118,67 +101,17 @@ export default function CollectionFilterBar({
|
||||
}),
|
||||
]
|
||||
|
||||
// Minimum-average-rating filter (#1435): All, then ≥5…≥1 stars.
|
||||
const ratingOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all') },
|
||||
...[5, 4, 3, 2, 1].map(n => ({
|
||||
key: n,
|
||||
label: `${n}+`,
|
||||
icon: <Star size={13} color="#facc15" fill="#facc15" />,
|
||||
})),
|
||||
]
|
||||
|
||||
// Display order: the saved order, or alphabetical by name.
|
||||
const sortOpts: Opt[] = [
|
||||
{ key: 'default', label: t('collections.sort.default') },
|
||||
{ key: 'name_asc', label: t('collections.sort.nameAsc') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="col-filterbar">
|
||||
{canAddPlace && (
|
||||
<button type="button" onClick={onAddPlace} className="col-filter-btn col-filter-add" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
)}
|
||||
<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} />} />
|
||||
)}
|
||||
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
|
||||
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp 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-filter-btn col-filter-addlabel" 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ function renderList(over: Partial<{
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
labels={[]}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -8,7 +8,6 @@ import StatusBadge from './StatusBadge'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
labels: CollectionLabel[]
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
@@ -24,9 +23,8 @@ interface CollectionListProps {
|
||||
* open the place (or toggle it in select mode).
|
||||
*/
|
||||
export default function CollectionList({
|
||||
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
|
||||
// Bring the selected row into view — e.g. when it was picked from the map.
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -38,7 +36,6 @@ export default function CollectionList({
|
||||
{places.map(place => {
|
||||
const selected = selectedIds.includes(place.id)
|
||||
const active = selectedPlaceId === place.id
|
||||
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
@@ -64,12 +61,6 @@ export default function CollectionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,9 @@ interface CollectionMapProps {
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
const center: [number, number] = pts.length > 0
|
||||
? [pts[0].lat as number, pts[0].lng as number]
|
||||
: [48.8566, 2.3522]
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
@@ -32,8 +35,8 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
hoverDisabled
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
// falls back to the world view for a collection with none.
|
||||
center={center}
|
||||
zoom={pts.length > 0 ? 6 : 3}
|
||||
tileUrl={tileUrl}
|
||||
fitKey={pts.length}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
|
||||
import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import CollectionMap from './CollectionMap'
|
||||
@@ -15,6 +15,9 @@ interface CollectionMapPanelProps {
|
||||
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
|
||||
view: 'list' | 'map'
|
||||
onToggleView: () => void
|
||||
/** Show a "+" to add a place to the current list (real lists only). */
|
||||
canAddPlace: boolean
|
||||
onAddPlace: () => void
|
||||
search: string
|
||||
onSearch: (v: string) => void
|
||||
t: TranslationFn
|
||||
@@ -27,7 +30,7 @@ interface CollectionMapPanelProps {
|
||||
*/
|
||||
export default function CollectionMapPanel({
|
||||
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
|
||||
search, onSearch, t,
|
||||
canAddPlace, onAddPlace, search, onSearch, t,
|
||||
}: CollectionMapPanelProps): React.ReactElement {
|
||||
return (
|
||||
<div className="col-map-shell">
|
||||
@@ -52,6 +55,11 @@ export default function CollectionMapPanel({
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-map-group right">
|
||||
{canAddPlace && (
|
||||
<button type="button" onClick={onAddPlace} className="col-map-btn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
|
||||
<Plus size={17} />
|
||||
</button>
|
||||
)}
|
||||
<div className="col-map-search">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
|
||||
|
||||
@@ -39,7 +39,6 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
labels: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
@@ -139,26 +138,4 @@ describe('CollectionPlaceDetail', () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Custom cover image (#1136) ──────────────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-011: shows the cover upload control when canEdit && onUploadImage', async () => {
|
||||
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
|
||||
expect(await screen.findByRole('button', { name: 'Upload image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-012: hides the cover upload control when onUploadImage is not provided', async () => {
|
||||
renderDetail({ canEdit: true });
|
||||
// Wait for the mount photo effect to settle before asserting absence.
|
||||
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-013: removing a custom cover calls onSave with { image_url: null }', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
|
||||
const props = renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage });
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
|
||||
expect(props.onSave).toHaveBeenCalledWith({ image_url: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,15 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
function linkHost(url: string): string {
|
||||
@@ -26,19 +22,13 @@ interface CollectionPlaceDetailProps {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** The active list's custom labels, for the assign chips. */
|
||||
labels: CollectionLabel[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null }) => Promise<void>
|
||||
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
/** Cast/clear the current user's star vote (#1435); every member may vote. */
|
||||
onRate?: (rating: number | null) => Promise<void> | void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -66,19 +56,14 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
|
||||
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const coverInputRef = useRef<HTMLInputElement>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
|
||||
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
@@ -92,9 +77,6 @@ export default function CollectionPlaceDetail({
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
setLat(place.lat != null ? String(place.lat) : '')
|
||||
setLng(place.lng != null ? String(place.lng) : '')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
@@ -113,49 +95,14 @@ export default function CollectionPlaceDetail({
|
||||
}, [place.id])
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
|
||||
const handleCoverPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !onUploadImage) return
|
||||
setImgBusy(true)
|
||||
try {
|
||||
await onUploadImage(await normalizeImageFile(file))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
||||
} finally {
|
||||
setImgBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
setImgBusy(true)
|
||||
try {
|
||||
await onSave({ image_url: null })
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
||||
} finally {
|
||||
setImgBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -178,33 +125,6 @@ export default function CollectionPlaceDetail({
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
||||
{canEdit && onUploadImage && (
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
|
||||
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
|
||||
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{place.image_url && !imgBusy && (
|
||||
<Tooltip label={t('places.removeImage')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageRemove}
|
||||
aria-label={t('places.removeImage')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
|
||||
</div>
|
||||
)}
|
||||
<div className="col-detail-head">
|
||||
{editing
|
||||
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
||||
@@ -223,13 +143,6 @@ export default function CollectionPlaceDetail({
|
||||
{/* Status — live for editors, read-only for viewers */}
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
||||
{onRate && (
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
{/* Category */}
|
||||
@@ -248,30 +161,6 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Coordinates */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
|
||||
<div className="col-detail-link-row">
|
||||
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className="col-detail-input flex-1" />
|
||||
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className="col-detail-input flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
||||
<div className="col-detail-cats">
|
||||
{labels.map(l => {
|
||||
const on = labelIds.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
@@ -295,15 +184,6 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
|
||||
|
||||
interface LabelManagerProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
onCreate: (name: string, color?: string) => Promise<void> | void
|
||||
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
|
||||
onDelete: (labelId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** Swatch row shared by the create form and each row's recolor control. */
|
||||
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
|
||||
style={{ background: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
|
||||
function LabelRow({ label, onUpdate, onDelete, t }: {
|
||||
label: CollectionLabel
|
||||
onUpdate: LabelManagerProps['onUpdate']
|
||||
onDelete: LabelManagerProps['onDelete']
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(label.name)
|
||||
const [color, setColor] = useState(label.color || '#6366f1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const commitName = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === label.name) { setName(label.name); return }
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
|
||||
}
|
||||
const pickColor = async (c: string) => {
|
||||
setColor(c)
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
|
||||
aria-label={t('collections.labels.name')}
|
||||
/>
|
||||
<Swatches value={color} onPick={pickColor} />
|
||||
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
|
||||
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a list's custom labels — create, rename, recolor and delete. Available
|
||||
* to any member who can edit the list; the labels are shared by the whole list.
|
||||
*/
|
||||
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const add = async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || adding) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await onCreate(trimmed, newColor)
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }}
|
||||
maxLength={60}
|
||||
placeholder={t('collections.labels.namePlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!newName.trim() || adding}
|
||||
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 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
|
||||
</button>
|
||||
</div>
|
||||
<Swatches value={newColor} onPick={setNewColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import MIconBtn from '../../mobile/components/MIconBtn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
|
||||
* picker (load lists + membership, toggle the place in/out of each), dressed in
|
||||
* the mobile design language (MSheet card, m-* tokens) so it matches the place
|
||||
* detail sheet. Rendered instead of the desktop modal on phones (see App.tsx).
|
||||
*/
|
||||
export default function MSaveToCollectionSheet() {
|
||||
const target = useSaveToCollectionStore(s => s.target)
|
||||
const close = useSaveToCollectionStore(s => s.close)
|
||||
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
}
|
||||
}, [target])
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return
|
||||
try {
|
||||
setMembership(await collectionsApi.membership(membershipQuery))
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] })
|
||||
}
|
||||
}, [membershipQuery])
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setMembership(null)
|
||||
Promise.all([
|
||||
collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })),
|
||||
membershipQuery
|
||||
? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] }))
|
||||
: Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }),
|
||||
])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return
|
||||
setLists(listRes.collections)
|
||||
setMembership(m)
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId)
|
||||
toast.success(t('collections.removedFromList', { name: list.name }))
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
})
|
||||
toast.success(t('collections.addedToList', { name: list.name }))
|
||||
}
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={!!target} onClose={close} variant="card" material="glass" ariaLabel={t('collections.pickList')}>
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[1rem] font-bold leading-snug">{t('collections.pickList')}</div>
|
||||
{target?.name && (
|
||||
<div className="mt-[2px] truncate font-geist text-[0.6875rem] text-m-muted">{target.name}</div>
|
||||
)}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-[16px] pt-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center px-4 py-10 text-center">
|
||||
<span className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-[color:var(--m-ic)] text-m-faint">
|
||||
<Bookmark size={20} strokeWidth={2} />
|
||||
</span>
|
||||
<p className="mb-3 font-geist text-[0.75rem] text-m-faint">{t('collections.noListsYet')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-m-act px-4 py-[9px] text-[0.75rem] font-semibold text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busyId != null}
|
||||
className={`mt-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-left disabled:opacity-60 ${
|
||||
saved
|
||||
? 'border-[color:var(--m-act)] bg-[color:var(--m-inner)]'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-xl text-white"
|
||||
style={{ background: list.color || '#6366f1' }}
|
||||
>
|
||||
<Bookmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
|
||||
{t('collections.shared')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full ${
|
||||
saved ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] text-m-faint'
|
||||
}`}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : saved ? <BookmarkCheck size={14} strokeWidth={2} /> : <Check size={14} strokeWidth={2} />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="text-[0.78125rem] font-semibold text-[color:var(--m-act)]"
|
||||
>
|
||||
{t('collections.viewInCollection')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded-full bg-[color:var(--m-ic)] px-4 py-[8px] text-[0.78125rem] font-semibold text-m-ink"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isWalletPass } from './FileManager.helpers';
|
||||
|
||||
describe('isWalletPass (#1447)', () => {
|
||||
it('detects by extension when the mime is unreliable', () => {
|
||||
// Browsers frequently send octet-stream / empty for .pkpass uploads
|
||||
expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true);
|
||||
expect(isWalletPass(null, 'multi.pkpasses')).toBe(true);
|
||||
expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the wallet MIME types when there is no extension', () => {
|
||||
expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true);
|
||||
expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for non-wallet files', () => {
|
||||
expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false);
|
||||
expect(isWalletPass('image/png', 'photo.png')).toBe(false);
|
||||
expect(isWalletPass('text/markdown', 'notes.md')).toBe(false);
|
||||
expect(isWalletPass(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,18 +26,6 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) {
|
||||
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Wallet pass (#1447). Detected by EXTENSION first — browsers often send an
|
||||
* empty / octet-stream MIME for .pkpass — falling back to the wallet MIME types.
|
||||
* Wallet passes must be downloaded so the OS hands them to Apple Wallet rather
|
||||
* than rendered in the in-app PDF preview.
|
||||
*/
|
||||
export function isWalletPass(mimeType?: string | null, name?: string | null) {
|
||||
const ext = (name || '').toLowerCase().split('.').pop()
|
||||
if (ext === 'pkpass' || ext === 'pkpasses') return true
|
||||
return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
|
||||
@@ -15,15 +15,6 @@ vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||
}));
|
||||
|
||||
// Mock the blob download/open helpers so we can assert wallet passes are
|
||||
// downloaded (#1447) rather than opened in the in-app PDF preview.
|
||||
vi.mock('../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
downloadFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload';
|
||||
|
||||
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
|
||||
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
|
||||
vi.mock('react-markdown', () => ({
|
||||
@@ -322,21 +313,6 @@ describe('FileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('boarding.pkpass'));
|
||||
|
||||
// Blob helper is called with the file url + name — the OS hands it to Wallet
|
||||
await waitFor(() => {
|
||||
expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass');
|
||||
});
|
||||
// No PDF preview modal — the filename appears only once (in the list row)
|
||||
expect(screen.getAllByText('boarding.pkpass').length).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Upload, Star } from 'lucide-react'
|
||||
import { Upload, FileText, Star } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { FileRow } from './FileManagerRow'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function FilesView(S: FileManagerState) {
|
||||
const {
|
||||
can, trip, getRootProps, getInputProps, isDragActive, uploading, t, allowedFileTypes,
|
||||
files, filterType, setFilterType, filteredFiles,
|
||||
} = S
|
||||
const contribFor = usePluginViewContributions('files', S.tripId)
|
||||
return (
|
||||
<>
|
||||
{/* Upload zone */}
|
||||
@@ -67,18 +63,14 @@ export function FilesView(S: FileManagerState) {
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState scene="files" title={t('files.empty')} />
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredFiles.map(file => {
|
||||
const contributions = contribFor(file.id)
|
||||
return (
|
||||
<Fragment key={file.id}>
|
||||
<FileRow {...S} file={file} />
|
||||
{contributions.length > 0 && <div style={{ padding: '0 4px' }}><PluginCardFooter items={contributions} tripId={S.tripId} /></div>}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{filteredFiles.map(file => <FileRow key={file.id} {...S} file={file} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload'
|
||||
import { isImage, isMedia } from './FileManager.helpers'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
@@ -192,10 +191,6 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
if (isMedia(file.mime_type)) {
|
||||
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else if (isWalletPass(file.mime_type, file.original_name)) {
|
||||
// Download so the OS hands the pass to Apple Wallet (#1447) rather than
|
||||
// forcing it into the in-app PDF preview.
|
||||
openFileInTab(file.url, file.original_name).catch(() => {})
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export function MoodChip({ mood }: { mood: string }) {
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10.5px] font-semibold" style={{ background: config.bg, color: config.text }}>
|
||||
<Icon size={12} />
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: config.bg, color: config.text }}>
|
||||
<Icon size={11} />
|
||||
{t(config.label)}
|
||||
</div>
|
||||
)
|
||||
@@ -20,8 +20,8 @@ export function WeatherChip({ weather }: { weather: string }) {
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10.5px] font-semibold" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink2)' }}>
|
||||
<Icon size={12} />
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<Icon size={11} />
|
||||
{t(config.label)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
@@ -23,19 +21,6 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
const { t } = useTranslation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
// Extra rows contributed by journalEntryProvider plugins — same pattern as the
|
||||
// PlaceInspector provider details: fetched only when plugins are active at all,
|
||||
// fail-safe (the server drops slow/failing providers), only ever additive.
|
||||
const hasPlugins = usePluginStore((s) => s.plugins.length > 0)
|
||||
const [providerRows, setProviderRows] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
|
||||
useEffect(() => {
|
||||
if (!hasPlugins) { setProviderRows([]); return }
|
||||
let cancelled = false
|
||||
pluginsApi.journalEntryRows(entry.id)
|
||||
.then((d) => { if (!cancelled) setProviderRows((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) })
|
||||
.catch(() => { if (!cancelled) setProviderRows([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [entry.id, hasPlugins])
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
@@ -45,7 +30,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-[20px] overflow-hidden transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-md" style={{ border: '1px solid var(--vg-line)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
|
||||
{/* Hero area: photos with title overlay */}
|
||||
{photos.length > 0 ? (
|
||||
@@ -161,20 +146,6 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin provider rows — host-vetted label/value/url, plain text only */}
|
||||
{providerRows.length > 0 && (
|
||||
<div className="pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800 space-y-1.5">
|
||||
{providerRows.flatMap((p) => p.items.map((it, i) => (
|
||||
<div key={`${p.pluginId}-${i}`} className="flex items-baseline justify-between gap-2 text-[12px]">
|
||||
<span className="font-medium text-zinc-500 dark:text-zinc-400 flex-shrink-0">{it.label}</span>
|
||||
{it.url
|
||||
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-indigo-600 dark:text-indigo-400 truncate text-right">{it.value ?? it.url}</a>
|
||||
: <span className="text-zinc-600 dark:text-zinc-300 truncate text-right">{it.value}</span>}
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -185,25 +156,22 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`rounded-[18px] px-3.5 py-3 flex items-center gap-3 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:-translate-y-0.5 cursor-pointer' : ''}`}
|
||||
style={{ border: '1.5px dashed var(--vg-line2)', background: 'var(--vg-surf2)' }}
|
||||
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0" style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink3)' }}>
|
||||
<MapPin size={15} />
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-semibold truncate" style={{ color: 'var(--vg-ink)' }}>
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">
|
||||
{entry.title || t('journey.detail.newEntry')}
|
||||
</div>
|
||||
<div className="text-[11px] mt-0.5 truncate" style={{ color: 'var(--vg-ink3)' }}>
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{onClick && (
|
||||
<span className="inline-flex items-center gap-1 flex-shrink-0 rounded-full px-3 py-1.5 text-[11px] font-semibold" style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}>
|
||||
<Plus size={12} strokeWidth={2.6} /> {t('journey.detail.addEntry')}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||
{t('journey.detail.addEntry')} →
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, mapsApi, addonsApi } from '../../api/client'
|
||||
import { journeyApi, mapsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl, isValidGeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
tripDates: Set<string>
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
userId?: number
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
@@ -52,14 +48,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(false)
|
||||
const [externalProvider, setExternalProvider] = useState<string | null>(null)
|
||||
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
// warn before discarding on close/cancel.
|
||||
@@ -78,48 +68,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
pendingLinkIds.length > 0 ||
|
||||
pendingProviderGroups.length > 0
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||
|
||||
useEffect(() => {
|
||||
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
|
||||
let cancelled = false
|
||||
setProvidersLoading(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const addonsData = await addonsApi.enabled()
|
||||
const enabled = (addonsData.addons || []).filter((a: any) => a.type === 'photo_provider' && a.enabled)
|
||||
const connected: { id: string; name: string }[] = []
|
||||
for (const provider of enabled) {
|
||||
try {
|
||||
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
||||
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
|
||||
} catch {}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setAvailableProviders(connected)
|
||||
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
|
||||
}
|
||||
} catch {}
|
||||
if (!cancelled) setProvidersLoading(false)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [photoTab, availableProviders.length])
|
||||
|
||||
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
|
||||
const providerExistingAssetIds = new Set<string>()
|
||||
if (activeExternalProvider) {
|
||||
photos.forEach(photo => {
|
||||
if (photo.provider === activeExternalProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
|
||||
})
|
||||
pendingProviderGroups.forEach(group => {
|
||||
if (group.provider === activeExternalProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
@@ -139,9 +92,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)) ? 'entry' : undefined),
|
||||
}, persistedEntryIdRef.current ?? undefined)
|
||||
if (entryId > 0) persistedEntryIdRef.current = entryId
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const filesToUpload = pendingFiles
|
||||
@@ -166,18 +118,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
|
||||
}
|
||||
}
|
||||
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
|
||||
const failed: PendingProviderGroup[] = []
|
||||
for (const group of pendingProviderGroups) {
|
||||
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
setPendingProviderGroups(failed)
|
||||
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
|
||||
return
|
||||
}
|
||||
setPendingProviderGroups([])
|
||||
}
|
||||
onDone()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -193,31 +133,26 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
setPendingFiles(prev => [...prev, ...normalized])
|
||||
}
|
||||
|
||||
const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN })
|
||||
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
|
||||
{/* The modal itself is constrained to the feed column on desktop so it
|
||||
centers there — but the backdrop stays full-width (covering the map
|
||||
too) for a uniform dim/blur across the whole page. */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-end sm:items-center sm:justify-center sm:p-5"
|
||||
className="absolute top-0 bottom-0 left-0 flex items-end sm:items-center sm:justify-center sm:p-5"
|
||||
style={{ right: isMobile ? 0 : 'clamp(420px, 44vw, 760px)' }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-[24px] sm:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[1040px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 items-stretch">
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-4">
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
@@ -229,9 +164,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={saving}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{uploadProgress ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
@@ -241,8 +176,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</button>
|
||||
{galleryPhotos.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setPhotoTab('gallery'); setShowGalleryPick(!showGalleryPick) }}
|
||||
className={`flex-1 border rounded-xl py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
|
||||
onClick={() => setShowGalleryPick(!showGalleryPick)}
|
||||
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
|
||||
showGalleryPick
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
@@ -251,17 +186,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
|
||||
photoTab === 'external'
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gallery picker — directly below buttons. Safari collapses
|
||||
@@ -285,7 +209,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
setPhotos(prev => [...prev, gp])
|
||||
}
|
||||
}}
|
||||
className="relative w-full rounded-xl overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
style={{ paddingTop: '100%' }}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
@@ -297,90 +221,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{photoTab === 'external' && (
|
||||
<div className="mt-2 flex flex-col border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden bg-zinc-50 dark:bg-zinc-800/50" style={{ height: 'min(56vh, 520px)' }}>
|
||||
<div className="px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200 truncate">
|
||||
{t('journey.editor.externalPhotosFor', { date: new Date(entryDate + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) })}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400 truncate">
|
||||
{contextLocation?.name
|
||||
? `${t('journey.editor.externalPhotosNearby') || 'Nearby photos first'} · ${contextLocation.name}`
|
||||
: (t('journey.editor.externalPhotosNoLocation') || 'All photos from this day')}
|
||||
</p>
|
||||
</div>
|
||||
{pendingProviderGroups.length > 0 && (
|
||||
<button onClick={() => setPendingProviderGroups([])} className="text-[10px] text-zinc-500 hover:text-zinc-900 dark:hover:text-white whitespace-nowrap">
|
||||
{pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)} {t('journey.editor.externalPhotosQueued') || 'queued'} · {t('common.clear') || 'Clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{providersLoading ? (
|
||||
<div className="flex justify-center py-8"><div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-700 rounded-full animate-spin" /></div>
|
||||
) : availableProviders.length === 0 ? (
|
||||
<div className="text-center py-10 px-4 text-[12px] text-zinc-500">{t('journey.editor.externalPhotosUnavailable') || 'No connected photo providers are available.'}</div>
|
||||
) : (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className="flex gap-1 px-3 py-2 border-b border-zinc-200 dark:border-zinc-700 overflow-x-auto">
|
||||
{availableProviders.map(provider => (
|
||||
<button
|
||||
key={provider.id}
|
||||
data-testid={`journey-external-provider-${provider.id}`}
|
||||
onClick={() => setExternalProvider(provider.id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap ${externalProvider === provider.id ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700'}`}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeExternalProvider && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ProviderPicker
|
||||
key={`${activeExternalProvider}-${entryDate}`}
|
||||
provider={activeExternalProvider}
|
||||
userId={userId}
|
||||
entries={[entry]}
|
||||
trips={trips}
|
||||
existingAssetIds={providerExistingAssetIds}
|
||||
initialDate={entryDate}
|
||||
contextLocation={contextLocation}
|
||||
initialEntryId={entry.id || null}
|
||||
embedded
|
||||
onClose={() => setExternalProvider(null)}
|
||||
onAdd={async groups => {
|
||||
setPendingProviderGroups(previous => {
|
||||
const next = [...previous]
|
||||
for (const group of groups) {
|
||||
const existing = next.find(item => item.provider === activeExternalProvider && item.passphrase === group.passphrase)
|
||||
if (existing) {
|
||||
const seen = new Set(existing.assetIds)
|
||||
group.assetIds.forEach((assetId, index) => {
|
||||
if (seen.has(assetId)) return
|
||||
seen.add(assetId)
|
||||
existing.assetIds.push(assetId)
|
||||
existing.mediaTypes?.push(group.mediaTypes?.[index] || 'image')
|
||||
})
|
||||
} else {
|
||||
next.push({ ...group, provider: activeExternalProvider })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setExternalProvider(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(photos.length > 0 || pendingFiles.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
||||
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
@@ -420,7 +265,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
|
||||
<div key={`pending-${i}`} className="w-20 h-20 rounded-lg overflow-hidden relative group">
|
||||
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
|
||||
<button
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
@@ -435,7 +280,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-[220px] border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden focus-within:border-zinc-400 dark:focus-within:border-zinc-500">
|
||||
<div className="shrink-0 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden focus-within:border-zinc-400 dark:focus-within:border-zinc-500">
|
||||
<MarkdownToolbar textareaRef={storyRef} onUpdate={setStory} />
|
||||
<textarea
|
||||
ref={storyRef}
|
||||
@@ -444,13 +289,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
rows={6}
|
||||
style={{ minHeight: '144px' }}
|
||||
className="w-full flex-1 px-3 py-2.5 text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none resize-none border-0"
|
||||
className="w-full px-3 py-2.5 text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none resize-none border-0 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
{/* Pros & Cons */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
|
||||
<div className="mb-4">
|
||||
@@ -527,6 +369,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
@@ -561,6 +405,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
placeholder={t('journey.editor.searchLocation')}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
||||
/>
|
||||
{locationLat && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLocationResults && locationResults.length > 0 && (
|
||||
<>
|
||||
@@ -599,13 +448,13 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(MOOD_CONFIG).map(([key, config]) => {
|
||||
const Icon = config.icon
|
||||
const active = mood === key
|
||||
return (
|
||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-all ${
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||
}`}
|
||||
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||
@@ -625,7 +474,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const active = weather === key
|
||||
return (
|
||||
<button key={key} onClick={() => setWeather(active ? '' : key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-all ${
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
}`}>
|
||||
<Icon size={12} />
|
||||
@@ -635,14 +484,12 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={handleClose} className="px-4 h-10 flex items-center rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-5 h-10 flex items-center rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 transition-colors">
|
||||
<button onClick={handleClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, X, Play } from 'lucide-react'
|
||||
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
@@ -10,9 +10,8 @@ import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
@@ -20,7 +19,6 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
trips: JourneyTrip[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
@@ -63,7 +61,6 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
}
|
||||
|
||||
const galleryFileRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => { onRegisterUpload?.(() => galleryFileRef.current?.click()) }, [onRegisterUpload])
|
||||
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
@@ -122,10 +119,21 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => galleryFileRef.current?.click()}
|
||||
disabled={galleryUploading}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
</button>
|
||||
{availableProviders.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -140,7 +148,13 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
</div>
|
||||
|
||||
{allPhotos.length === 0 ? (
|
||||
<EmptyState scene="journey" title={t('journey.detail.noPhotos')} />
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Image size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">{t('journey.detail.noPhotos')}</p>
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{t('journey.detail.noPhotosHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map((photo, i) => (
|
||||
@@ -180,7 +194,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<div className="absolute top-1.5 left-1.5">
|
||||
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
|
||||
<RefreshCw size={7} />
|
||||
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synologyphotos' ? 'Synology Photos' : photo.provider}
|
||||
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,27 +2,21 @@ import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { groupPhotosByDate } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
@@ -36,7 +30,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
@@ -110,9 +104,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate)
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
@@ -125,11 +117,6 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
)
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
@@ -148,38 +135,24 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
>
|
||||
<div
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
@@ -205,16 +178,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-[36px] flex items-center">
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
@@ -267,11 +231,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button
|
||||
@@ -316,11 +280,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
{!loading && photos.length > 0 && (() => {
|
||||
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
@@ -354,7 +318,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
) : photos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
@@ -362,13 +326,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
|
||||
@@ -88,20 +88,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5 items-start">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-5 rounded-2xl p-4" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
|
||||
{/*Cover Image */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
|
||||
{/* Cover Image */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
|
||||
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
|
||||
@@ -126,7 +123,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,20 +134,18 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
value={subtitle}
|
||||
onChange={e => setSubtitle(e.target.value)}
|
||||
placeholder={t('journey.settings.subtitlePlaceholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-5 rounded-2xl p-4" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
|
||||
{/*Synced Trips */}
|
||||
{/* Synced Trips */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.syncedTrips')}</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{journey.trips.map((trip: any) => (
|
||||
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-xl bg-zinc-50 dark:bg-zinc-800">
|
||||
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800">
|
||||
<div className="w-8 h-8 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
|
||||
@@ -158,7 +153,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
|
||||
className="w-8 h-8 rounded-xl flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
|
||||
className="w-8 h-8 rounded-lg flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
|
||||
title="Unlink trip"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -168,7 +163,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
|
||||
<button
|
||||
onClick={() => setShowAddTrip(true)}
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<Plus size={14} /> {t('journey.trips.addTrip')}
|
||||
</button>
|
||||
@@ -185,7 +180,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
{(c.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||
<span className="shrink-0 rounded-full font-semibold uppercase" style={{ fontSize: 8.5, letterSpacing: '0.05em', padding: '2px 7px', ...(c.role === 'owner' ? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' } : { background: 'color-mix(in srgb, var(--vg-ink3) 14%, transparent)', color: 'var(--vg-ink2)' }) }}>{c.role}</span>
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -200,7 +195,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
title={t('journey.contributors.remove')}
|
||||
className="w-7 h-7 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
@@ -209,17 +204,14 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
))}
|
||||
<button
|
||||
onClick={onOpenInvite}
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<UserPlus size={14} /> {t('journey.contributors.invite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-3" />
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{/* Public Share */}
|
||||
<JourneyShareSection journeyId={journey.id} />
|
||||
@@ -232,7 +224,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
aria-label={t('journey.settings.delete')}
|
||||
title={t('journey.settings.delete')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-3 md:px-3.5 text-[12px] font-semibold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
|
||||
@@ -242,13 +234,13 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
disabled={archiving}
|
||||
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
title={t('journey.settings.endDescription')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-3 md:px-3.5 text-[12px] font-semibold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-full mr-auto disabled:opacity-40 transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
|
||||
>
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||
</button>
|
||||
<button onClick={handleClose} className="h-10 px-4 rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-10 px-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 transition-colors">
|
||||
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -362,9 +362,6 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
mapRef.current = map
|
||||
|
||||
@@ -57,28 +57,22 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
{!link ? (
|
||||
<button
|
||||
onClick={createLink}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<Link size={14} /> {t('journey.share.createLink')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* URL + Copy */}
|
||||
<div className="flex items-center gap-2 p-2 rounded-2xl bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
||||
<Link size={13} className="text-zinc-400 flex-shrink-0 ml-1.5" />
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
||||
<Link size={13} className="text-zinc-400 flex-shrink-0" />
|
||||
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-semibold hover:bg-zinc-700 dark:hover:bg-zinc-200 transition-colors"
|
||||
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
{t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permission toggles */}
|
||||
@@ -91,7 +85,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-[12px] font-medium transition-all ${
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
@@ -103,6 +97,14 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete link */}
|
||||
<button
|
||||
onClick={deleteLink}
|
||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||
>
|
||||
{t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BottomNav from './BottomNav';
|
||||
@@ -114,21 +113,4 @@ describe('BottomNav', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense');
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: page plugin renders the icon its manifest declares', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
|
||||
});
|
||||
const { container } = render(<BottomNav />);
|
||||
expect(screen.getByText('Trip Doctor')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: page plugin with an unknown icon falls back to Blocks', () => {
|
||||
seedStore(usePluginStore, {
|
||||
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
|
||||
});
|
||||
const { container } = render(<BottomNav />);
|
||||
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useNavigate, useLocation, useMatch } from 'react-router-dom'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
@@ -54,9 +52,6 @@ export default function BottomNav() {
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
|
||||
// nav pill (Navbar) — otherwise they were only reachable by typing /plugins/:id.
|
||||
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
|
||||
const location = useLocation()
|
||||
const create = useCreateAction()
|
||||
|
||||
@@ -66,7 +61,6 @@ export default function BottomNav() {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||
}),
|
||||
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
|
||||
]
|
||||
// Split the items so the raised "+" sits dead centre.
|
||||
const splitAt = Math.ceil(items.length / 2)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('DemoBanner', () => {
|
||||
it('self-host link points to GitHub', () => {
|
||||
render(<DemoBanner />);
|
||||
const link = screen.getByText('self-host it').closest('a')!;
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user