mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 249ab217f8 | |||
| 499097fa3c | |||
| 002ea91be8 | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 | |||
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a |
@@ -30,3 +30,7 @@ sonar-project.properties
|
|||||||
server/tests/
|
server/tests/
|
||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
|
**/*.test.ts
|
||||||
|
wiki/
|
||||||
|
scripts/
|
||||||
|
charts/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I am running the latest available version of TREK
|
- label: I am running the latest available version of TREK
|
||||||
required: true
|
required: true
|
||||||
|
- 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
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Publish to GitHub wiki
|
- name: Publish to GitHub wiki
|
||||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
with:
|
with:
|
||||||
strategy: init
|
strategy: clone
|
||||||
|
|||||||
+2
-1
@@ -60,4 +60,5 @@ coverage
|
|||||||
.scannerwork
|
.scannerwork
|
||||||
test-data
|
test-data
|
||||||
|
|
||||||
.run
|
.run
|
||||||
|
.full-review
|
||||||
@@ -6,19 +6,29 @@
|
|||||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
### Your trips. Your plan. Your server.
|
<br />
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||||
|
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
|
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
|
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
|
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||||
|
<br />
|
||||||
|
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<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 />
|
<br />
|
||||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
<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/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://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>
|
||||||
@@ -117,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
- **Dashboard widgets** — currency converter and timezone clocks
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🤖 AI / MCP
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
@@ -142,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -162,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
|||||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -328,7 +342,8 @@ server {
|
|||||||
ssl_certificate /etc/ssl/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 50m;
|
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -345,6 +360,7 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -384,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Trademark Policy
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||||
|
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||||
|
license does not include a license to use our trademarks.
|
||||||
|
|
||||||
|
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||||
|
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||||
|
indicators of the software we release; and 2) our community members' desire to
|
||||||
|
be full participants in the TREK project.
|
||||||
|
|
||||||
|
## Our trademarks
|
||||||
|
|
||||||
|
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||||
|
goodwill, or designs (our "Marks").
|
||||||
|
|
||||||
|
## In general
|
||||||
|
|
||||||
|
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||||
|
anyone about exactly who is the source of the software. For example, you cannot
|
||||||
|
say you are distributing TREK when you're distributing a modified version of it,
|
||||||
|
because people would think they would be getting the same software that they
|
||||||
|
can get directly from us when they aren't. You also cannot use our Marks on
|
||||||
|
your website in a way that suggests that your website is an official TREK
|
||||||
|
website or that we endorse your website. But, if true, you can say you like
|
||||||
|
TREK, that you participate in the TREK community, that you are providing an
|
||||||
|
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||||
|
TREK.
|
||||||
|
|
||||||
|
This fundamental requirement, that it is always clear to people what they are
|
||||||
|
getting and from whom, is reflected throughout this policy. It should also
|
||||||
|
serve as your guide if you are not sure about how you are using the Marks.
|
||||||
|
|
||||||
|
In addition:
|
||||||
|
|
||||||
|
* You may not use or register, in whole or in part, the Marks as part of your
|
||||||
|
own trademark, service mark, domain name, company name, trade name, product
|
||||||
|
name or service name.
|
||||||
|
* Trademark law does not allow your use of names or trademarks that are too
|
||||||
|
similar to ours. You therefore may not use an obvious variation of any of our
|
||||||
|
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||||
|
abbreviation for a similar or compatible product or service.
|
||||||
|
* You agree that you will not acquire any rights in the Marks and that any
|
||||||
|
goodwill generated by your use of the Marks and participation in our
|
||||||
|
community inures solely to our benefit.
|
||||||
|
|
||||||
|
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||||
|
|
||||||
|
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||||
|
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||||
|
the software to identify your redistribution. This kind of use only applies if
|
||||||
|
you are redistributing an official TREK distribution that has not been changed
|
||||||
|
in any way.
|
||||||
|
|
||||||
|
## Distribution of executable code that you have compiled, or modified code
|
||||||
|
|
||||||
|
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||||
|
describe the origin of the software that you are providing, that is, that the
|
||||||
|
code you are distributing is a modification of TREK. You may say, for example,
|
||||||
|
that "this software is derived from the source code for TREK."
|
||||||
|
|
||||||
|
Of course, you can place your own trademarks or logos on versions of the
|
||||||
|
software to which you have made substantive modifications, because by modifying
|
||||||
|
the software, you have become the origin of that exact version. In that case,
|
||||||
|
you should not use our Marks.
|
||||||
|
|
||||||
|
However, you may use our Marks for the distribution of code (source or
|
||||||
|
executable) on the condition that any executable is built from an official TREK
|
||||||
|
source code release and that any modifications are limited to switching on or
|
||||||
|
off features already included in the software, translations into other
|
||||||
|
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||||
|
further modification is not permitted.
|
||||||
|
|
||||||
|
## Mobile wrappers, hosted instances, and forks
|
||||||
|
|
||||||
|
The following clarifications apply specifically to common ways TREK is
|
||||||
|
redistributed:
|
||||||
|
|
||||||
|
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||||
|
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||||
|
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||||
|
Official," etc.).
|
||||||
|
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||||
|
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||||
|
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||||
|
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||||
|
unless your wrapper distributes only an unmodified, official TREK instance
|
||||||
|
and you have obtained permission.
|
||||||
|
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||||
|
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||||
|
of TREK," but the project name itself must be your own.
|
||||||
|
|
||||||
|
## Statements about your software's relation to TREK
|
||||||
|
|
||||||
|
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||||
|
relationship between your software and ours. The word mark "TREK" should be
|
||||||
|
used after a verb or preposition that describes the relationship between your
|
||||||
|
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||||
|
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||||
|
|
||||||
|
* [Your software] uses TREK
|
||||||
|
* [Your software] is powered by TREK
|
||||||
|
* [Your software] runs on TREK
|
||||||
|
* [Your software] for use with TREK
|
||||||
|
* [Your software] for TREK
|
||||||
|
|
||||||
|
## Questions and permission requests
|
||||||
|
|
||||||
|
If you are not sure whether your intended use of the Marks is permitted under
|
||||||
|
this policy, or if you would like to request explicit permission for a use that
|
||||||
|
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||||
|
the maintainers directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These guidelines are based on the
|
||||||
|
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||||
|
under a
|
||||||
|
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 2.9.14
|
version: 3.0.9
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.9"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data:
|
|||||||
{{- if .Values.env.FORCE_HTTPS }}
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ env:
|
|||||||
# Also used as the base URL for links in email notifications and other external links.
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# FORCE_HTTPS: "false"
|
# FORCE_HTTPS: "false"
|
||||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||||
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
@@ -8907,9 +8907,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -356,9 +356,13 @@ export const journeyApi = {
|
|||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||||
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||||
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
const items = grouped[oldName] || []
|
const items = grouped.get(oldName) || []
|
||||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
}
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
|
|||||||
label: string
|
label: string
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JourneyMapHandle {
|
export interface JourneyMapHandle {
|
||||||
@@ -24,6 +26,8 @@ interface MapEntry {
|
|||||||
title?: string | null
|
title?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
label: e.title || 'Entry',
|
label: e.title || 'Entry',
|
||||||
mood: e.mood,
|
mood: e.mood,
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const fill = dark
|
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
|
||||||
const textColor = dark
|
|
||||||
? (highlighted ? '#18181B' : '#18181B')
|
|
||||||
: (highlighted ? '#fff' : '#fff')
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
|
||||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
|
||||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const label = String(index + 1)
|
const label = String(dayLabel)
|
||||||
const scale = highlighted ? 1.2 : 1
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
|
||||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(prev)
|
const marker = markersRef.current.get(prev)
|
||||||
const item = itemsRef.current.find(i => i.id === prev)
|
const item = itemsRef.current.find(i => i.id === prev)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, false, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(0)
|
marker.setZIndexOffset(0)
|
||||||
}
|
}
|
||||||
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, true, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(1000)
|
marker.setZIndexOffset(1000)
|
||||||
}
|
}
|
||||||
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(i, false, !!dark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
const marker = L.marker(pos, { icon }).addTo(map)
|
const marker = L.marker(pos, { icon }).addTo(map)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,6 +41,8 @@ interface Item {
|
|||||||
label: string
|
label: string
|
||||||
locationName: string
|
locationName: string
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
|||||||
label: e.title || '',
|
label: e.title || '',
|
||||||
locationName: e.location_name || '',
|
locationName: e.location_name || '',
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
|||||||
document.head.appendChild(s)
|
document.head.appendChild(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
const fill = dark
|
const fill = dayColor
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
const textColor = '#fff'
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
|
||||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
|
||||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const scale = highlighted ? 1.2 : 1
|
const scale = highlighted ? 1.2 : 1
|
||||||
const label = String(index + 1)
|
const label = String(dayLabel)
|
||||||
|
|
||||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||||
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
|
|||||||
inner.className = 'trek-journey-marker-inner'
|
inner.className = 'trek-journey-marker-inner'
|
||||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>`
|
</svg>`
|
||||||
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
if (!item || !marker) return
|
if (!item || !marker) return
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
const el = marker.getElement()
|
const el = marker.getElement()
|
||||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
if (!currentInner) return
|
if (!currentInner) return
|
||||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
// would wipe mapbox's positional transform and make the marker flicker.
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
currentInner.style.cssText = nextInner.style.cssText
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
currentInner.innerHTML = nextInner.innerHTML
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// markers
|
// markers
|
||||||
items.forEach((item, i) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(i, false, !!darkRef.current)
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||||
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||||
index: number
|
dayLabel: number
|
||||||
|
dayColor: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||||
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||||
{/* Day number + date + mood/weather */}
|
{/* Day number + date + mood/weather */}
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||||
{index + 1}
|
{dayLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
{hasLocation ? (
|
{hasLocation ? (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||||
<MapPin size={10} className="flex-shrink-0" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ThumbsUp, ThumbsDown, ChevronDown,
|
ThumbsUp, ThumbsDown, ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import JournalBody from './JournalBody'
|
import JournalBody from './JournalBody'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||||
@@ -24,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
|||||||
cold: { icon: Snowflake, label: 'Cold' },
|
cold: { icon: Snowflake, label: 'Cold' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||||
|
if (builder) return builder(p.photo_id)
|
||||||
return `/api/photos/${p.photo_id}/${size}`
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||||
const photos = entry.photos || []
|
const photos = entry.photos || []
|
||||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||||
@@ -84,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photos[0])}
|
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||||
onClick={() => onPhotoClick(photos, 0)}
|
onClick={() => onPhotoClick(photos, 0)}
|
||||||
@@ -101,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{photos.map((p, i) => (
|
{photos.map((p, i) => (
|
||||||
<img
|
<img
|
||||||
key={p.id || i}
|
key={p.id || i}
|
||||||
src={photoUrl(p, 'thumbnail')}
|
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||||
onClick={() => onPhotoClick(photos, i)}
|
onClick={() => onPhotoClick(photos, i)}
|
||||||
@@ -130,7 +133,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||||
{entry.location_name}
|
{formatLocationName(entry.location_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import JourneyMap from './JourneyMap'
|
import JourneyMap from './JourneyMap'
|
||||||
import MobileEntryCard from './MobileEntryCard'
|
import MobileEntryCard from './MobileEntryCard'
|
||||||
import type { JourneyMapHandle } from './JourneyMap'
|
import type { JourneyMapHandle } from './JourneyMap'
|
||||||
import type { JourneyEntry } from '../../store/journeyStore'
|
import type { JourneyEntry } from '../../store/journeyStore'
|
||||||
|
import { DAY_COLORS } from './dayColors'
|
||||||
|
|
||||||
interface MapEntry {
|
interface MapEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
onEntryClick: (entry: any) => void
|
onEntryClick: (entry: any) => void
|
||||||
onAddEntry?: () => void
|
onAddEntry?: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
carouselBottom?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMapTimeline({
|
export default function MobileMapTimeline({
|
||||||
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
|
|||||||
onEntryClick,
|
onEntryClick,
|
||||||
onAddEntry,
|
onAddEntry,
|
||||||
publicPhotoUrl,
|
publicPhotoUrl,
|
||||||
|
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const carouselRef = useRef<HTMLDivElement>(null)
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
||||||
const activeIndexRef = useRef(activeIndex)
|
|
||||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
|
||||||
|
|
||||||
|
const entryDayMeta = useMemo(() => {
|
||||||
|
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
|
||||||
|
const counters = new Map<string, number>()
|
||||||
|
return entries.map((e: any) => {
|
||||||
|
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||||
|
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||||
|
counters.set(e.entry_date, dayLabel)
|
||||||
|
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
|
||||||
|
})
|
||||||
|
}, [entries])
|
||||||
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||||
const syncMapToCarousel = useCallback((index: number) => {
|
const syncMapToCarousel = useCallback((index: number) => {
|
||||||
const entry = entries[index]
|
const entry = entries[index]
|
||||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
|||||||
})
|
})
|
||||||
}, [syncMapToCarousel])
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
// Track scroll; debounce to re-center the active card when the user stops.
|
// Defer all state updates until scrolling settles — updating activeIndex
|
||||||
|
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = carouselRef.current
|
const el = carouselRef.current
|
||||||
if (!el || entries.length === 0) return
|
if (!el || entries.length === 0) return
|
||||||
let rafId: number | null = null
|
|
||||||
let settleTimer: number | null = null
|
let settleTimer: number | null = null
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId != null) return
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
pickNearestCard()
|
|
||||||
rafId = null
|
|
||||||
})
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
settleTimer = window.setTimeout(() => {
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
// Ensure the active card sits at the center once the user settles.
|
|
||||||
const card = cardRefs.current.get(activeIndexRef.current)
|
|
||||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
|
||||||
}, 180)
|
|
||||||
}
|
}
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('scroll', onScroll)
|
el.removeEventListener('scroll', onScroll)
|
||||||
if (rafId != null) cancelAnimationFrame(rafId)
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
}
|
}
|
||||||
}, [entries.length, pickNearestCard])
|
}, [entries.length, pickNearestCard])
|
||||||
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
<JourneyMap
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
{/* Full-screen map */}
|
{/* Full-screen map */}
|
||||||
<JourneyMap
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
|||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 right-0 z-40"
|
className="fixed left-0 right-0 z-40"
|
||||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||||
style={{
|
style={{
|
||||||
scrollSnapType: 'x proximity',
|
scrollSnapType: 'x mandatory',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
|
|||||||
>
|
>
|
||||||
<MobileEntryCard
|
<MobileEntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
index={i}
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||||
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||||
isActive={i === activeIndex}
|
isActive={i === activeIndex}
|
||||||
onClick={() => handleCardTap(entry, i)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const DAY_COLORS = [
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#f97316', // orange
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#22c55e', // green
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#84cc16', // lime
|
||||||
|
'#f43f5e', // rose
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#fb923c', // orange-400
|
||||||
|
'#60a5fa', // blue-400
|
||||||
|
'#c084fc', // purple-400
|
||||||
|
'#34d399', // emerald-400
|
||||||
|
'#fbbf24', // amber-400
|
||||||
|
'#e879f9', // fuchsia
|
||||||
|
'#4ade80', // green-400
|
||||||
|
'#f87171', // red-400
|
||||||
|
'#38bdf8', // sky-400
|
||||||
|
'#a3e635', // lime-400
|
||||||
|
'#fb7185', // rose-400
|
||||||
|
'#818cf8', // indigo-400
|
||||||
|
'#2dd4bf', // teal-400
|
||||||
|
'#facc15', // yellow
|
||||||
|
'#c026d3', // fuchsia-600
|
||||||
|
'#0ea5e9', // sky-500
|
||||||
|
]
|
||||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 99999,
|
||||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 16, overflow: 'auto',
|
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||||
|
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||||
|
paddingLeft: 16, paddingRight: 16,
|
||||||
|
overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
maxWidth: 480, width: '100%',
|
maxWidth: 480, width: '100%',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
maxHeight: '90vh', overflow: 'auto',
|
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
position: 'sticky', bottom: 0, background: 'white',
|
||||||
|
marginTop: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={13} />
|
<Github size={13} />
|
||||||
|
|||||||
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
navigate('/login', { state: { noRedirect: true } })
|
navigate('/login', { state: { noRedirect: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep track of the pending theme-transition cleanup so we can cancel it
|
||||||
|
// on unmount. Without this the timer fires after jsdom teardown in unit
|
||||||
|
// tests (document is gone) and triggers an unhandled ReferenceError that
|
||||||
|
// trips vitest's exit code.
|
||||||
|
const themeTransitionTimer = useRef<number | null>(null)
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (themeTransitionTimer.current !== null) {
|
||||||
|
window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
document.documentElement.classList.add('trek-theme-transitioning')
|
document.documentElement.classList.add('trek-theme-transitioning')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
window.setTimeout(() => {
|
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = window.setTimeout(() => {
|
||||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
themeTransitionTimer.current = null
|
||||||
}, 360)
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
* offline + N queued → amber bar "Offline — N changes queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber bar "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue bar "Syncing N changes…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
* online + 0 pending → hidden
|
* online + 0 pending → hidden
|
||||||
|
*
|
||||||
|
* Rendered as a small floating pill anchored to the bottom-center of the
|
||||||
|
* viewport so it never competes with top navigation or sticky modal
|
||||||
|
* headers. On mobile it hovers just above the bottom tab bar.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
const label = offline
|
const label = offline
|
||||||
? pendingCount > 0
|
? pendingCount > 0
|
||||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
? `Offline · ${pendingCount} queued`
|
||||||
: 'Offline'
|
: 'Offline'
|
||||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||||
left: 0,
|
// so the pill sits 16px from the bottom.
|
||||||
right: 0,
|
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
background: bg,
|
background: bg,
|
||||||
color: text,
|
color: text,
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
gap: 8,
|
padding: '6px 14px',
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
borderRadius: 999,
|
||||||
paddingBottom: '6px',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||||
paddingLeft: '16px',
|
fontSize: 12,
|
||||||
paddingRight: '16px',
|
fontWeight: 600,
|
||||||
fontSize: 13,
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 500,
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{offline
|
||||||
? <WifiOff size={14} />
|
? <WifiOff size={12} />
|
||||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
|||||||
id: 400,
|
id: 400,
|
||||||
title: 'Flight to Rome',
|
title: 'Flight to Rome',
|
||||||
type: 'flight',
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
reservation_time: '2025-06-01T14:30:00',
|
reservation_time: '2025-06-01T14:30:00',
|
||||||
confirmation_number: 'ABC123',
|
confirmation_number: 'ABC123',
|
||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async function fetchPlacePhotos(assignments) {
|
|||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
toFetch.map(async (place) => {
|
toFetch.map(async (place) => {
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id)
|
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
|
||||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const totalCost = Object.values(assignments || {})
|
const totalCost = Object.values(assignments || {})
|
||||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||||
|
|
||||||
|
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||||
|
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||||
|
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (!startId || startId === endId) return 'single'
|
||||||
|
if (dayId === startId) return 'start'
|
||||||
|
if (dayId === endId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||||
|
const phase = pdfGetSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||||
|
if (phase === 'single') return null
|
||||||
|
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||||
|
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||||
|
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||||
|
}
|
||||||
|
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (startId == null) return false
|
||||||
|
if (endId !== startId) {
|
||||||
|
const startDay = sorted.find(d => d.id === startId)
|
||||||
|
const endDay = sorted.find(d => d.id === endId)
|
||||||
|
const thisDay = sorted.find(d => d.id === dayId)
|
||||||
|
if (!startDay || !endDay || !thisDay) return false
|
||||||
|
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||||
|
}
|
||||||
|
return startId === dayId
|
||||||
|
})
|
||||||
|
|
||||||
// Build day HTML
|
// Build day HTML
|
||||||
const daysHtml = sorted.map((day, di) => {
|
const daysHtml = sorted.map((day, di) => {
|
||||||
const assigned = assignments[String(day.id)] || []
|
const assigned = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
// Reservations for this day (hotel rendered via accommodations block)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
dayReservations.forEach(r => {
|
dayReservations.forEach(r => {
|
||||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
merged.push({ type: 'reservation', k: pos, data: r })
|
merged.push({ type: 'reservation', k: pos, data: r })
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
|
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
<div class="note-line" style="background: ${color};"></div>
|
<div class="note-line" style="background: ${color};"></div>
|
||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
|||||||
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
|
|||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A category's first item is seeded with this sentinel because the server
|
||||||
|
// rejects empty names. Treat it as a placeholder in the UI.
|
||||||
|
const PACKING_PLACEHOLDER_NAME = '...'
|
||||||
|
|
||||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||||
|
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||||
@@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
|
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||||
catch { toast.error(t('packing.toast.saveError')) }
|
catch { toast.error(t('packing.toast.saveError')) }
|
||||||
}
|
}
|
||||||
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
{editing && canEdit ? (
|
{editing && canEdit ? (
|
||||||
<input
|
<input
|
||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
|
placeholder={isPlaceholder ? '...' : undefined}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
onBlur={handleSaveName}
|
onBlur={handleSaveName}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
const fmtTime = (v) => formatTime12(v, is12h)
|
const fmtTime = (v) => {
|
||||||
|
if (!v) return v
|
||||||
|
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
|
return formatTime12(v, is12h)
|
||||||
|
}
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const collapsed = collapsedProp
|
const collapsed = collapsedProp
|
||||||
const toggleCollapse = () => onToggleCollapse?.()
|
const toggleCollapse = () => onToggleCollapse?.()
|
||||||
@@ -168,7 +172,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
|||||||
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{res.reservation_time?.includes('T') && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontWeight: 400 }}>
|
<span style={{ fontWeight: 400 }}>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
{res.reservation_end_time && ` – ${(() => {
|
||||||
|
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||||
|
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||||
|
})()}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export default function PlaceFormModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSaving || hasTimeError}
|
||||||
|
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||||
|
>
|
||||||
|
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{/* Place Search */}
|
{/* Place Search */}
|
||||||
@@ -613,23 +632,6 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSaving || hasTimeError}
|
|
||||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
|
||||||
>
|
|
||||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
|
|||||||
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
||||||
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
||||||
|
|
||||||
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
|
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
|
||||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
|
||||||
|
// the form by tag instead of walking up from the button.
|
||||||
|
const form = document.querySelector('form')!;
|
||||||
fireEvent.submit(form);
|
fireEvent.submit(form);
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
let combinedEndTime = form.reservation_end_time
|
let combinedEndTime = form.reservation_end_time
|
||||||
if (form.end_date) {
|
if (form.end_date) {
|
||||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||||
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
if (isBudgetEnabled) {
|
||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
@@ -271,7 +273,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
|
||||||
|
size="2xl"
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
|
||||||
|
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
{/* Type selector */}
|
{/* Type selector */}
|
||||||
@@ -417,12 +434,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.hotel_place_id}
|
value={form.hotel_place_id}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
set('hotel_place_id', value)
|
|
||||||
const p = places.find(pl => pl.id === value)
|
const p = places.find(pl => pl.id === value)
|
||||||
if (p) {
|
setForm(prev => {
|
||||||
if (!form.title) set('title', p.name)
|
const next = { ...prev, hotel_place_id: value }
|
||||||
if (!form.location && p.address) set('location', p.address)
|
if (!value) {
|
||||||
}
|
next.location = ''
|
||||||
|
} else if (p) {
|
||||||
|
if (!prev.title) next.title = p.name
|
||||||
|
if (!prev.location && p.address) next.location = p.address
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
placeholder={t('reservations.meta.pickHotel')}
|
placeholder={t('reservations.meta.pickHotel')}
|
||||||
options={[
|
options={[
|
||||||
@@ -617,15 +639,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
|
||||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
|
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,17 +112,30 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
|
|
||||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
const isHotel = r.type === 'hotel'
|
||||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||||
const dayLabel = (day: typeof startDay): string => {
|
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||||
if (!day) return ''
|
: undefined
|
||||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||||
if (day.date) {
|
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||||
const d = new Date(day.date + 'T00:00:00Z')
|
: undefined
|
||||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const DayLabel = ({ day }: { day: typeof startDay }) => {
|
||||||
return `${base} · ${dateStr}`
|
if (!day) return null
|
||||||
}
|
const name = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
return base
|
const badge = day.date
|
||||||
|
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span>{name}</span>
|
||||||
|
{badge && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
|
||||||
|
}}>{badge}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — wraps to a second row on narrow screens so the status/category chips
|
||||||
|
never collide with the title. */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||||
@@ -202,12 +217,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||||
{/* Day label for transport reservations linked to a day */}
|
{/* Day label for transport/hotel reservations linked to days */}
|
||||||
{isTransportType && startDay && (
|
{(isTransportType || isHotel) && startDay && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
<DayLabel day={startDay} />
|
||||||
|
{endDay && endDay.id !== startDay.id && (
|
||||||
|
<><span style={{ color: 'var(--text-faint)' }}>–</span><DayLabel day={endDay} /></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtDate(r.reservation_time)}
|
{fmtDate(r.reservation_time)}
|
||||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
{(() => {
|
||||||
|
const endDatePart = r.reservation_end_time
|
||||||
|
? r.reservation_end_time.includes('T')
|
||||||
|
? r.reservation_end_time.split('T')[0]
|
||||||
|
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||||
|
? r.reservation_end_time
|
||||||
|
: null
|
||||||
|
: null
|
||||||
|
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||||
|
})() && (
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
<> – {fmtDate(r.reservation_end_time)}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
|
|||||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate } from '../../utils/formatters'
|
||||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ const defaultForm = {
|
|||||||
arrival_time: '',
|
arrival_time: '',
|
||||||
confirmation_number: '',
|
confirmation_number: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
price: '',
|
||||||
|
budget_category: '',
|
||||||
meta_airline: '',
|
meta_airline: '',
|
||||||
meta_flight_number: '',
|
meta_flight_number: '',
|
||||||
meta_train_number: '',
|
meta_train_number: '',
|
||||||
@@ -94,6 +98,13 @@ interface TransportModalProps {
|
|||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const budgetCategories = useMemo(() => {
|
||||||
|
const cats = new Set<string>()
|
||||||
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
|
return Array.from(cats).sort()
|
||||||
|
}, [budgetItems])
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
meta_train_number: meta.train_number || '',
|
meta_train_number: meta.train_number || '',
|
||||||
meta_platform: meta.platform || '',
|
meta_platform: meta.platform || '',
|
||||||
meta_seat: meta.seat || '',
|
meta_seat: meta.seat || '',
|
||||||
|
price: meta.price || '',
|
||||||
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||||
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId])
|
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
if (form.price) metadata.price = form.price
|
||||||
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
|
}
|
||||||
|
|
||||||
const startDate = startDay?.date ?? null
|
const startDate = startDay?.date ?? null
|
||||||
const endDate = (endDay ?? startDay)?.date ?? null
|
const endDate = (endDay ?? startDay)?.date ?? null
|
||||||
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||||
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
|
: { total_price: 0 }
|
||||||
|
}
|
||||||
await onSave(payload)
|
await onSave(payload)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
@@ -237,6 +259,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||||
|
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
@@ -412,15 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Price + Budget Category */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
{isBudgetEnabled && (
|
||||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
<>
|
||||||
{t('common.cancel')}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
</button>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
<input type="text" inputMode="decimal" value={form.price}
|
||||||
</button>
|
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||||
</div>
|
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||||
|
placeholder="0.00"
|
||||||
|
style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.budget_category}
|
||||||
|
onChange={v => set('budget_category', v)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||||
|
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||||
|
]}
|
||||||
|
placeholder={t('reservations.budgetCategoryAuto')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.price && parseFloat(form.price) > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||||
|
{t('reservations.budgetHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
|
|||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
await user.click(screen.getByText('24h (14:30)'));
|
// The label is split across a text node ('24h') and a responsive span (' (14:30)').
|
||||||
|
// Click the button that contains the 24h text instead of matching the full string.
|
||||||
|
await user.click(screen.getByRole('button', { name: /24h/ }));
|
||||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: '24h', label: '24h (14:30)' },
|
{ value: '24h', short: '24h', example: '14:30' },
|
||||||
{ value: '12h', label: '12h (2:30 PM)' },
|
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.short}
|
||||||
|
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
|
||||||
{t('settings.mapExperimental')}
|
|
||||||
</span>
|
|
||||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
<span className="sm:hidden">Mapbox</span>
|
||||||
|
<span className="hidden sm:inline">Mapbox GL</span>
|
||||||
|
</div>
|
||||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
|
||||||
|
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||||
|
{t('settings.mapExperimental')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast'
|
|||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
|
|
||||||
interface ProviderField {
|
interface ProviderField {
|
||||||
key: string
|
key: string
|
||||||
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
<div key={`${provider.id}-${field.key}`}>
|
<div key={`${provider.id}-${field.key}`}>
|
||||||
{field.input_type === 'checkbox' ? (
|
{field.input_type === 'checkbox' ? (
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
on={values[field.key] === 'true'}
|
||||||
checked={values[field.key] === 'true'}
|
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
|
||||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
|
|
||||||
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||||
</label>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||||
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
{/* Wraps on mobile so the connection badge drops to its own row
|
||||||
|
instead of clipping off the side of the card. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveProvider(provider)}
|
onClick={() => handleSaveProvider(provider)}
|
||||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||||
@@ -266,15 +267,17 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{testing
|
{testing
|
||||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||||
: <Camera className="w-4 h-4" />}
|
: <Camera className="w-4 h-4" />}
|
||||||
{t('memories.testConnection')}
|
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||||
|
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
{t('memories.connected')}
|
{t('memories.connected')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||||
{t('memories.disconnected')}
|
{t('memories.disconnected')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React from 'react'
|
|||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onToggle}
|
<button type="button" onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
|
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
interface CopyTripDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
tripTitle: string
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WILL_COPY_KEYS = [
|
||||||
|
'dashboard.confirm.copy.will1',
|
||||||
|
'dashboard.confirm.copy.will2',
|
||||||
|
'dashboard.confirm.copy.will3',
|
||||||
|
'dashboard.confirm.copy.will4',
|
||||||
|
'dashboard.confirm.copy.will5',
|
||||||
|
'dashboard.confirm.copy.will6',
|
||||||
|
]
|
||||||
|
|
||||||
|
const WONT_COPY_KEYS = [
|
||||||
|
'dashboard.confirm.copy.wont1',
|
||||||
|
'dashboard.confirm.copy.wont2',
|
||||||
|
'dashboard.confirm.copy.wont3',
|
||||||
|
'dashboard.confirm.copy.wont4',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) document.addEventListener('keydown', handleEsc)
|
||||||
|
return () => document.removeEventListener('keydown', handleEsc)
|
||||||
|
}, [isOpen, handleEsc])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
|
||||||
|
style={{ background: 'var(--bg-card)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{t('dashboard.confirm.copy.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{tripTitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
|
||||||
|
{t('dashboard.confirm.copy.willCopy')}
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{WILL_COPY_KEYS.map(key => (
|
||||||
|
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
|
||||||
|
{t(key)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('dashboard.confirm.copy.wontCopy')}
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{WONT_COPY_KEYS.map(key => (
|
||||||
|
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
{t(key)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-5">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onConfirm(); onClose() }}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{t('dashboard.confirm.copy.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -119,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
|||||||
...(() => {
|
...(() => {
|
||||||
const r = ref.current?.getBoundingClientRect()
|
const r = ref.current?.getBoundingClientRect()
|
||||||
if (!r) return { top: 0, left: 0 }
|
if (!r) return { top: 0, left: 0 }
|
||||||
const w = 268, pad = 8
|
const w = 268, pad = 8, h = 360
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||||
let left = r.left
|
let left = r.left
|
||||||
let top = r.bottom + 4
|
let top = r.bottom + 4
|
||||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
if (top + h > vh - pad) top = r.top - h - 4
|
||||||
|
top = Math.max(pad, Math.min(top, vh - h - pad))
|
||||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||||
return { top, left }
|
return { top, left }
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -61,14 +61,15 @@ export default function Modal({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
trek-modal-enter
|
trek-modal-enter
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
flex flex-col
|
||||||
|
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||||
`}
|
`}
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — stays put even while the body scrolls */}
|
||||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||||
{!hideCloseButton && (
|
{!hideCloseButton && (
|
||||||
<button
|
<button
|
||||||
@@ -80,14 +81,14 @@ export default function Modal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'لا شيء',
|
'common.none': 'لا شيء',
|
||||||
'common.date': 'التاريخ',
|
'common.date': 'التاريخ',
|
||||||
'common.rename': 'إعادة تسمية',
|
'common.rename': 'إعادة تسمية',
|
||||||
|
'common.discardChanges': 'تجاهل التغييرات',
|
||||||
|
'common.discard': 'تجاهل',
|
||||||
'common.name': 'الاسم',
|
'common.name': 'الاسم',
|
||||||
'common.email': 'البريد الإلكتروني',
|
'common.email': 'البريد الإلكتروني',
|
||||||
'common.password': 'كلمة المرور',
|
'common.password': 'كلمة المرور',
|
||||||
@@ -1623,6 +1625,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||||
'memories.testConnection': 'اختبار الاتصال',
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
|
'memories.testShort': 'اختبار',
|
||||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
'memories.connected': 'متصل',
|
'memories.connected': 'متصل',
|
||||||
'memories.disconnected': 'غير متصل',
|
'memories.disconnected': 'غير متصل',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nenhum',
|
'common.none': 'Nenhum',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Renomear',
|
'common.rename': 'Renomear',
|
||||||
|
'common.discardChanges': 'Descartar alterações',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Senha',
|
'common.password': 'Senha',
|
||||||
@@ -1662,6 +1664,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
||||||
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Testar conexão',
|
'memories.testConnection': 'Testar conexão',
|
||||||
|
'memories.testShort': 'Testar',
|
||||||
'memories.testFirst': 'Teste a conexão primeiro',
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'Não conectado',
|
'memories.disconnected': 'Não conectado',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Žádné',
|
'common.none': 'Žádné',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Přejmenovat',
|
'common.rename': 'Přejmenovat',
|
||||||
|
'common.discardChanges': 'Zahodit změny',
|
||||||
|
'common.discard': 'Zahodit',
|
||||||
'common.name': 'Jméno',
|
'common.name': 'Jméno',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Heslo',
|
'common.password': 'Heslo',
|
||||||
@@ -1621,6 +1623,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
||||||
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Otestovat připojení',
|
'memories.testConnection': 'Otestovat připojení',
|
||||||
|
'memories.testShort': 'Otestovat',
|
||||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
'memories.connected': 'Připojeno',
|
'memories.connected': 'Připojeno',
|
||||||
'memories.disconnected': 'Nepřipojeno',
|
'memories.disconnected': 'Nepřipojeno',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Keine',
|
'common.none': 'Keine',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Umbenennen',
|
'common.rename': 'Umbenennen',
|
||||||
|
'common.discardChanges': 'Änderungen verwerfen',
|
||||||
|
'common.discard': 'Verwerfen',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'E-Mail',
|
'common.email': 'E-Mail',
|
||||||
'common.password': 'Passwort',
|
'common.password': 'Passwort',
|
||||||
@@ -1625,6 +1627,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
||||||
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbindung testen',
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Verbindung zuerst testen',
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
'memories.connected': 'Verbunden',
|
'memories.connected': 'Verbunden',
|
||||||
'memories.disconnected': 'Nicht verbunden',
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'None',
|
'common.none': 'None',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Rename',
|
'common.rename': 'Rename',
|
||||||
|
'common.discardChanges': 'Discard Changes',
|
||||||
|
'common.discard': 'Discard',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
@@ -122,6 +124,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.toast.copied': 'Trip copied!',
|
'dashboard.toast.copied': 'Trip copied!',
|
||||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||||
|
'dashboard.confirm.copy.title': 'Copy this trip?',
|
||||||
|
'dashboard.confirm.copy.willCopy': 'Will be copied',
|
||||||
|
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
|
||||||
|
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
|
||||||
|
'dashboard.confirm.copy.will3': 'Budget items & category order',
|
||||||
|
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
|
||||||
|
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
|
||||||
|
'dashboard.confirm.copy.will6': 'Day notes',
|
||||||
|
'dashboard.confirm.copy.wontCopy': "Won't be copied",
|
||||||
|
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
|
||||||
|
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
|
||||||
|
'dashboard.confirm.copy.wont3': 'Files & photos',
|
||||||
|
'dashboard.confirm.copy.wont4': 'Share tokens',
|
||||||
|
'dashboard.confirm.copy.confirm': 'Copy trip',
|
||||||
'dashboard.editTrip': 'Edit Trip',
|
'dashboard.editTrip': 'Edit Trip',
|
||||||
'dashboard.createTrip': 'Create New Trip',
|
'dashboard.createTrip': 'Create New Trip',
|
||||||
'dashboard.tripTitle': 'Title',
|
'dashboard.tripTitle': 'Title',
|
||||||
@@ -1684,6 +1700,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
||||||
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connection',
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.testFirst': 'Test connection first',
|
'memories.testFirst': 'Test connection first',
|
||||||
'memories.connected': 'Connected',
|
'memories.connected': 'Connected',
|
||||||
'memories.disconnected': 'Not connected',
|
'memories.disconnected': 'Not connected',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
|
|||||||
'common.none': 'Ninguno',
|
'common.none': 'Ninguno',
|
||||||
'common.date': 'Fecha',
|
'common.date': 'Fecha',
|
||||||
'common.rename': 'Renombrar',
|
'common.rename': 'Renombrar',
|
||||||
|
'common.discardChanges': 'Descartar cambios',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nombre',
|
'common.name': 'Nombre',
|
||||||
'common.email': 'Correo',
|
'common.email': 'Correo',
|
||||||
'common.password': 'Contraseña',
|
'common.password': 'Contraseña',
|
||||||
@@ -1562,6 +1564,7 @@ const es: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
||||||
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Probar conexión',
|
'memories.testConnection': 'Probar conexión',
|
||||||
|
'memories.testShort': 'Probar',
|
||||||
'memories.testFirst': 'Probar conexión primero',
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'No conectado',
|
'memories.disconnected': 'No conectado',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
|
|||||||
'common.none': 'Aucun',
|
'common.none': 'Aucun',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Renommer',
|
'common.rename': 'Renommer',
|
||||||
|
'common.discardChanges': 'Ignorer les modifications',
|
||||||
|
'common.discard': 'Ignorer',
|
||||||
'common.name': 'Nom',
|
'common.name': 'Nom',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Mot de passe',
|
'common.password': 'Mot de passe',
|
||||||
@@ -1619,6 +1621,7 @@ const fr: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
||||||
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Tester la connexion',
|
'memories.testConnection': 'Tester la connexion',
|
||||||
|
'memories.testShort': 'Tester',
|
||||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
'memories.connected': 'Connecté',
|
'memories.connected': 'Connecté',
|
||||||
'memories.disconnected': 'Non connecté',
|
'memories.disconnected': 'Non connecté',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nincs',
|
'common.none': 'Nincs',
|
||||||
'common.date': 'Dátum',
|
'common.date': 'Dátum',
|
||||||
'common.rename': 'Átnevezés',
|
'common.rename': 'Átnevezés',
|
||||||
|
'common.discardChanges': 'Változtatások elvetése',
|
||||||
|
'common.discard': 'Elveti',
|
||||||
'common.name': 'Név',
|
'common.name': 'Név',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Jelszó',
|
'common.password': 'Jelszó',
|
||||||
@@ -1690,6 +1692,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
||||||
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||||
|
'memories.testShort': 'Teszt',
|
||||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
'memories.connected': 'Csatlakoztatva',
|
'memories.connected': 'Csatlakoztatva',
|
||||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Tidak ada',
|
'common.none': 'Tidak ada',
|
||||||
'common.date': 'Tanggal',
|
'common.date': 'Tanggal',
|
||||||
'common.rename': 'Ganti nama',
|
'common.rename': 'Ganti nama',
|
||||||
|
'common.discardChanges': 'Buang perubahan',
|
||||||
|
'common.discard': 'Buang',
|
||||||
'common.name': 'Nama',
|
'common.name': 'Nama',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Kata sandi',
|
'common.password': 'Kata sandi',
|
||||||
@@ -1682,6 +1684,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Uji koneksi',
|
'memories.testConnection': 'Uji koneksi',
|
||||||
|
'memories.testShort': 'Uji',
|
||||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||||
'memories.connected': 'Terhubung',
|
'memories.connected': 'Terhubung',
|
||||||
'memories.disconnected': 'Tidak terhubung',
|
'memories.disconnected': 'Tidak terhubung',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nessuno',
|
'common.none': 'Nessuno',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Rinomina',
|
'common.rename': 'Rinomina',
|
||||||
|
'common.discardChanges': 'Scarta modifiche',
|
||||||
|
'common.discard': 'Scarta',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
@@ -1620,6 +1622,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
||||||
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connessione',
|
'memories.testConnection': 'Test connessione',
|
||||||
|
'memories.testShort': 'Prova',
|
||||||
'memories.testFirst': 'Testa prima la connessione',
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
'memories.connected': 'Connesso',
|
'memories.connected': 'Connesso',
|
||||||
'memories.disconnected': 'Non connesso',
|
'memories.disconnected': 'Non connesso',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
|
|||||||
'common.none': 'Geen',
|
'common.none': 'Geen',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Hernoemen',
|
'common.rename': 'Hernoemen',
|
||||||
|
'common.discardChanges': 'Wijzigingen verwerpen',
|
||||||
|
'common.discard': 'Verwerpen',
|
||||||
'common.name': 'Naam',
|
'common.name': 'Naam',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Wachtwoord',
|
'common.password': 'Wachtwoord',
|
||||||
@@ -612,8 +614,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||||
'admin.collab.notes.title': 'Notities',
|
'admin.collab.notes.title': 'Notities',
|
||||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||||
'admin.collab.polls.title': 'Peilingen',
|
'admin.collab.polls.title': 'Polls',
|
||||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
|
||||||
'admin.collab.whatsnext.title': 'Wat nu',
|
'admin.collab.whatsnext.title': 'Wat nu',
|
||||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||||
'admin.tabs.config': 'Personalisatie',
|
'admin.tabs.config': 'Personalisatie',
|
||||||
@@ -1619,6 +1621,7 @@ const nl: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
||||||
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbinding testen',
|
'memories.testConnection': 'Verbinding testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Test eerst de verbinding',
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
'memories.connected': 'Verbonden',
|
'memories.connected': 'Verbonden',
|
||||||
'memories.disconnected': 'Niet verbonden',
|
'memories.disconnected': 'Niet verbonden',
|
||||||
@@ -1658,7 +1661,7 @@ const nl: Record<string, string> = {
|
|||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notities',
|
'collab.tabs.notes': 'Notities',
|
||||||
'collab.tabs.polls': 'Peilingen',
|
'collab.tabs.polls': 'Polls',
|
||||||
'collab.whatsNext.title': 'Wat komt er',
|
'collab.whatsNext.title': 'Wat komt er',
|
||||||
'collab.whatsNext.today': 'Vandaag',
|
'collab.whatsNext.today': 'Vandaag',
|
||||||
'collab.whatsNext.tomorrow': 'Morgen',
|
'collab.whatsNext.tomorrow': 'Morgen',
|
||||||
@@ -1704,7 +1707,7 @@ const nl: Record<string, string> = {
|
|||||||
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
||||||
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
||||||
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
||||||
'collab.polls.title': 'Peilingen',
|
'collab.polls.title': 'Polls',
|
||||||
'collab.polls.new': 'Nieuwe poll',
|
'collab.polls.new': 'Nieuwe poll',
|
||||||
'collab.polls.empty': 'Nog geen polls',
|
'collab.polls.empty': 'Nog geen polls',
|
||||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Brak',
|
'common.none': 'Brak',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Zmień nazwę',
|
'common.rename': 'Zmień nazwę',
|
||||||
|
'common.discardChanges': 'Odrzuć zmiany',
|
||||||
|
'common.discard': 'Odrzuć',
|
||||||
'common.name': 'Nazwa',
|
'common.name': 'Nazwa',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Hasło',
|
'common.password': 'Hasło',
|
||||||
@@ -1571,6 +1573,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
||||||
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test',
|
'memories.testConnection': 'Test',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.connected': 'Połączono',
|
'memories.connected': 'Połączono',
|
||||||
'memories.disconnected': 'Nie połączono',
|
'memories.disconnected': 'Nie połączono',
|
||||||
'memories.connectionSuccess': 'Połączono z Immich',
|
'memories.connectionSuccess': 'Połączono z Immich',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
|
|||||||
'common.none': 'Нет',
|
'common.none': 'Нет',
|
||||||
'common.date': 'Дата',
|
'common.date': 'Дата',
|
||||||
'common.rename': 'Переименовать',
|
'common.rename': 'Переименовать',
|
||||||
|
'common.discardChanges': 'Отменить изменения',
|
||||||
|
'common.discard': 'Отменить',
|
||||||
'common.name': 'Имя',
|
'common.name': 'Имя',
|
||||||
'common.email': 'Эл. почта',
|
'common.email': 'Эл. почта',
|
||||||
'common.password': 'Пароль',
|
'common.password': 'Пароль',
|
||||||
@@ -1619,6 +1621,7 @@ const ru: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Проверить подключение',
|
'memories.testConnection': 'Проверить подключение',
|
||||||
|
'memories.testShort': 'Проверить',
|
||||||
'memories.testFirst': 'Сначала проверьте подключение',
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
'memories.connected': 'Подключено',
|
'memories.connected': 'Подключено',
|
||||||
'memories.disconnected': 'Не подключено',
|
'memories.disconnected': 'Не подключено',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
|
|||||||
'common.none': '无',
|
'common.none': '无',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重命名',
|
'common.rename': '重命名',
|
||||||
|
'common.discardChanges': '放弃更改',
|
||||||
|
'common.discard': '放弃',
|
||||||
'common.name': '名称',
|
'common.name': '名称',
|
||||||
'common.email': '邮箱',
|
'common.email': '邮箱',
|
||||||
'common.password': '密码',
|
'common.password': '密码',
|
||||||
@@ -1619,6 +1621,7 @@ const zh: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '测试连接',
|
'memories.testConnection': '测试连接',
|
||||||
|
'memories.testShort': '测试',
|
||||||
'memories.testFirst': '请先测试连接',
|
'memories.testFirst': '请先测试连接',
|
||||||
'memories.connected': '已连接',
|
'memories.connected': '已连接',
|
||||||
'memories.disconnected': '未连接',
|
'memories.disconnected': '未连接',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
|
|||||||
'common.none': '無',
|
'common.none': '無',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重新命名',
|
'common.rename': '重新命名',
|
||||||
|
'common.discardChanges': '捨棄變更',
|
||||||
|
'common.discard': '捨棄',
|
||||||
'common.name': '名稱',
|
'common.name': '名稱',
|
||||||
'common.email': '郵箱',
|
'common.email': '郵箱',
|
||||||
'common.password': '密碼',
|
'common.password': '密碼',
|
||||||
@@ -1679,6 +1681,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '測試連線',
|
'memories.testConnection': '測試連線',
|
||||||
|
'memories.testShort': '測試',
|
||||||
'memories.testFirst': '請先測試連線',
|
'memories.testFirst': '請先測試連線',
|
||||||
'memories.connected': '已連線',
|
'memories.connected': '已連線',
|
||||||
'memories.disconnected': '未連線',
|
'memories.disconnected': '未連線',
|
||||||
|
|||||||
@@ -1240,6 +1240,15 @@ interface SidebarContentProps {
|
|||||||
|
|
||||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
|
const statsContentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = statsContentRef.current
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return
|
||||||
|
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
const tm = dark ? '#94a3b8' : '#64748b'
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
// Bucket list content
|
// Bucket list content
|
||||||
const bucketContent = (
|
const bucketContent = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
|
||||||
{bucketList.map(item => (
|
{bucketList.map(item => (
|
||||||
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1400,7 +1409,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{/* Both tabs always rendered so the wider one sets the panel width */}
|
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||||
<div style={{ display: 'grid' }}>
|
<div style={{ display: 'grid' }}>
|
||||||
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||||
<div className="flex items-stretch justify-center">
|
<div ref={statsContentRef} className="flex items-stretch justify-center">
|
||||||
|
|
||||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
{/* Countries hero */}
|
{/* Countries hero */}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
|||||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||||
await user.click(copyButtons[0]);
|
await user.click(copyButtons[0]);
|
||||||
|
|
||||||
|
// Confirm the copy dialog
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
|||||||
expect(copyButtons.length).toBeGreaterThan(0);
|
expect(copyButtons.length).toBeGreaterThan(0);
|
||||||
await user.click(copyButtons[0]);
|
await user.click(copyButtons[0]);
|
||||||
|
|
||||||
|
// Confirm the copy dialog
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
|||||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { useCountUp } from '../hooks/useCountUp'
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
setArchivedTrips(prev => prev.map(update))
|
setArchivedTrips(prev => prev.map(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (trip: DashboardTrip) => {
|
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||||
|
|
||||||
|
const confirmCopy = async () => {
|
||||||
|
if (!copyTrip) return
|
||||||
try {
|
try {
|
||||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
|
||||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.copied'))
|
toast.success(t('dashboard.toast.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.copyError'))
|
toast.error(t('dashboard.toast.copyError'))
|
||||||
}
|
}
|
||||||
|
setCopyTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTripDialog
|
||||||
|
isOpen={!!copyTrip}
|
||||||
|
tripTitle={copyTrip?.title || ''}
|
||||||
|
onClose={() => setCopyTrip(null)}
|
||||||
|
onConfirm={confirmCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
journey_id: 1,
|
||||||
|
photo_id: 100,
|
||||||
|
provider: 'local',
|
||||||
|
file_path: 'photos/test.jpg',
|
||||||
|
asset_id: null,
|
||||||
|
owner_id: null,
|
||||||
|
thumbnail_path: null,
|
||||||
|
caption: 'Colosseum',
|
||||||
|
sort_order: 0,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
shared: 1,
|
||||||
|
created_at: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||||
@@ -1468,7 +1486,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
||||||
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
|
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
@@ -1493,10 +1511,10 @@ describe('JourneyDetailPage', () => {
|
|||||||
await openSettingsDialog(user);
|
await openSettingsDialog(user);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Remove share link'));
|
await user.click(screen.getByText('Delete link'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deleteCalled).toBe(true);
|
expect(deleteCalled).toBe(true);
|
||||||
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
it('renders the empty gallery state when journey has no photos', async () => {
|
it('renders the empty gallery state when journey has no photos', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
// Override with entries that have no photos
|
// Override with entries that have no photos and empty gallery
|
||||||
const emptyEntry = {
|
const emptyEntry = {
|
||||||
...mockJourneyDetail.entries[0],
|
...mockJourneyDetail.entries[0],
|
||||||
photos: [],
|
photos: [],
|
||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [emptyEntry],
|
entries: [emptyEntry],
|
||||||
|
gallery: [],
|
||||||
stats: { entries: 1, photos: 0, places: 1 },
|
stats: { entries: 1, photos: 0, places: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
|
// Gallery photos render in a grid; each photo has a group container
|
||||||
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
|
const photos = document.querySelectorAll('[class*="aspect-square"]');
|
||||||
const dateOverlay = document.querySelector('[class*="opacity-0"]');
|
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(dateOverlay).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
|
||||||
|
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
|
||||||
|
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2905,7 +2933,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||||
// These reuse the same i18n keys as the main tab bar
|
// These reuse the same i18n keys as the main tab bar
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
||||||
it('uploading files in gallery creates an entry and uploads photos', async () => {
|
it('uploading files in gallery calls gallery upload API', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let createCalled = false;
|
|
||||||
let uploadCalled = false;
|
let uploadCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/journeys/1/entries', () => {
|
http.post('/api/journeys/1/gallery/photos', () => {
|
||||||
createCalled = true;
|
|
||||||
return HttpResponse.json({
|
|
||||||
id: 99, journey_id: 1, author_id: 1, type: 'entry',
|
|
||||||
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
|
|
||||||
location_lat: null, location_lng: null, mood: null, weather: null,
|
|
||||||
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
|
|
||||||
entry_time: null, photos: [], created_at: now, updated_at: now,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.post('/api/journeys/entries/99/photos', () => {
|
|
||||||
uploadCalled = true;
|
uploadCalled = true;
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json({ photos: [] });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
|
|||||||
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
||||||
await user.upload(fileInput, testFile);
|
await user.upload(fileInput, testFile);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createCalled).toBe(true);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(uploadCalled).toBe(true);
|
expect(uploadCalled).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/journeys/photos/100', () => {
|
http.delete('/api/journeys/1/gallery/100', () => {
|
||||||
deleteCalled = true;
|
deleteCalled = true;
|
||||||
return HttpResponse.json({ success: true });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
|||||||
import { addListener, removeListener } from '../api/websocket'
|
import { addListener, removeListener } from '../api/websocket'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
@@ -67,16 +69,18 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||||
const date = new Date(d + 'T00:00:00')
|
const date = new Date(d + 'T00:00:00')
|
||||||
|
// Pass the app's selected locale so weekday/month follow the UI language
|
||||||
|
// instead of the browser's navigator.language.
|
||||||
return {
|
return {
|
||||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||||
return `/api/photos/${p.photo_id}/${size}`
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
|
|||||||
const winner = lastPast || firstAhead
|
const winner = lastPast || firstAhead
|
||||||
if (winner) {
|
if (winner) {
|
||||||
setActiveEntryId(winner.id)
|
setActiveEntryId(winner.id)
|
||||||
mapRef.current?.highlightMarker(winner.id)
|
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||||
|
mapRef.current?.highlightMarker(winner.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
|
|||||||
[current?.entries]
|
[current?.entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
const sidebarMapItems = useMemo(() => {
|
||||||
id: String(e.id),
|
const allDates = [...new Set(
|
||||||
lat: e.location_lat!,
|
(current?.entries || [])
|
||||||
lng: e.location_lng!,
|
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||||
title: e.title || '',
|
.map(e => e.entry_date)
|
||||||
location_name: e.location_name || '',
|
.sort()
|
||||||
mood: e.mood,
|
)]
|
||||||
created_at: e.entry_date,
|
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||||
entry_date: e.entry_date,
|
const dayCounters = new Map<string, number>()
|
||||||
})), [mapEntries])
|
return sorted.map(e => {
|
||||||
|
const dayIdx = allDates.indexOf(e.entry_date)
|
||||||
|
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
|
||||||
|
dayCounters.set(e.entry_date, dayLabel)
|
||||||
|
return {
|
||||||
|
id: String(e.id),
|
||||||
|
lat: e.location_lat!,
|
||||||
|
lng: e.location_lng!,
|
||||||
|
title: e.title || '',
|
||||||
|
location_name: e.location_name || '',
|
||||||
|
mood: e.mood,
|
||||||
|
created_at: e.entry_date,
|
||||||
|
entry_date: e.entry_date,
|
||||||
|
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||||
|
dayLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [mapEntries, current?.entries])
|
||||||
|
|
||||||
|
const locatedEntryIdsRef = useRef(new Set<string>())
|
||||||
|
useEffect(() => {
|
||||||
|
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
|
||||||
|
}, [sidebarMapItems])
|
||||||
|
|
||||||
const tripDates = useMemo(() => {
|
const tripDates = useMemo(() => {
|
||||||
const dates = new Set<string>()
|
const dates = new Set<string>()
|
||||||
@@ -313,7 +341,7 @@ export default function JourneyDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
|
||||||
const dayGroups = groupByDate(timelineEntries)
|
const dayGroups = groupByDate(timelineEntries)
|
||||||
const sortedDates = [...dayGroups.keys()].sort()
|
const sortedDates = [...dayGroups.keys()].sort()
|
||||||
|
|
||||||
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
|
|||||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||||
: 'flex w-full overflow-hidden'
|
: 'flex w-full overflow-hidden'
|
||||||
}
|
}
|
||||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
|
||||||
>
|
>
|
||||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||||
<div
|
<div
|
||||||
@@ -430,7 +458,7 @@ export default function JourneyDetailPage() {
|
|||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? ''
|
? ''
|
||||||
: 'flex-1 overflow-y-auto journey-feed-scroll'
|
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||||
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
|
|||||||
|
|
||||||
{sortedDates.map((date, dayIdx) => {
|
{sortedDates.map((date, dayIdx) => {
|
||||||
const entries = dayGroups.get(date)!
|
const entries = dayGroups.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||||
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
|
||||||
{dayIdx + 1}
|
{dayIdx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -611,7 +639,7 @@ export default function JourneyDetailPage() {
|
|||||||
.catch(() => toast.error(t('common.errorOccurred')))
|
.catch(() => toast.error(t('common.errorOccurred')))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
|
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
|
||||||
{canReorder && (
|
{canReorder && (
|
||||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||||
<button
|
<button
|
||||||
@@ -665,10 +693,11 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
entries={current.entries}
|
entries={current.entries}
|
||||||
|
gallery={current.gallery || []}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
userId={useAuthStore.getState().user?.id || 0}
|
userId={useAuthStore.getState().user?.id || 0}
|
||||||
trips={current.trips}
|
trips={current.trips}
|
||||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||||
onRefresh={() => loadJourney(Number(id))}
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,7 +734,7 @@ export default function JourneyDetailPage() {
|
|||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
tripDates={tripDates}
|
tripDates={tripDates}
|
||||||
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
|
galleryPhotos={current.gallery || []}
|
||||||
onClose={() => setEditingEntry(null)}
|
onClose={() => setEditingEntry(null)}
|
||||||
onSave={async (data) => {
|
onSave={async (data) => {
|
||||||
let entryId = editingEntry.id
|
let entryId = editingEntry.id
|
||||||
@@ -733,7 +762,8 @@ export default function JourneyDetailPage() {
|
|||||||
journey={current}
|
journey={current}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
||||||
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
onOpenInvite={() => { setShowInvite(true) }}
|
||||||
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -816,7 +846,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||||
onLocationClick: (id: string) => void
|
onLocationClick: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
// group map entries by date
|
// group map entries by date
|
||||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||||
mapEntries.forEach((e, i) => {
|
mapEntries.forEach((e, i) => {
|
||||||
@@ -872,7 +902,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
{dates.map((date, dayIdx) => {
|
{dates.map((date, dayIdx) => {
|
||||||
const items = byDate.get(date)!
|
const items = byDate.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date}>
|
<div key={date}>
|
||||||
@@ -915,7 +945,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 truncate">
|
<div className="text-[11px] text-zinc-500 truncate">
|
||||||
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -942,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
|
|
||||||
// ── Gallery View ──────────────────────────────────────────────────────────
|
// ── Gallery View ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
|
gallery: GalleryPhoto[]
|
||||||
journeyId: number
|
journeyId: number
|
||||||
userId: number
|
userId: number
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -980,12 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
})()
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
const allPhotos = gallery
|
||||||
for (const e of entries) {
|
|
||||||
for (const p of e.photos) {
|
|
||||||
allPhotos.push({ photo: p, entry: e })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||||
|
|
||||||
@@ -1001,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryUploading(true)
|
||||||
try {
|
try {
|
||||||
// find existing "Gallery" entry or create one. The stored title is the
|
|
||||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
|
||||||
// do not send a translated label here.
|
|
||||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
|
||||||
let entryId = galleryEntry?.id
|
|
||||||
if (!entryId) {
|
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
|
||||||
title: 'Gallery',
|
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
entryId = entry.id
|
|
||||||
}
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const f of files) formData.append('photos', f)
|
for (const f of files) formData.append('photos', f)
|
||||||
await journeyApi.uploadPhotos(entryId, formData)
|
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1027,24 +1040,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePhoto = async (photoId: number) => {
|
const handleDeletePhoto = async (galleryPhotoId: number) => {
|
||||||
// Optimistic update — remove photo from local state immediately
|
|
||||||
const store = useJourneyStore.getState()
|
const store = useJourneyStore.getState()
|
||||||
if (store.current) {
|
if (!store.current) return
|
||||||
const updated = {
|
|
||||||
|
// Optimistic update — remove from gallery and all entry photo lists
|
||||||
|
useJourneyStore.setState({
|
||||||
|
current: {
|
||||||
...store.current,
|
...store.current,
|
||||||
|
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
|
||||||
entries: store.current.entries.map(e => ({
|
entries: store.current.entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
photos: e.photos.filter(p => p.id !== photoId),
|
photos: e.photos.filter(p => p.id !== galleryPhotoId),
|
||||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
})),
|
||||||
}
|
},
|
||||||
useJourneyStore.setState({ current: updated })
|
})
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
onRefresh() // Revert on error
|
onRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
<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, entry }, i) => (
|
{allPhotos.map((photo, i) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
onClick={() => onPhotoClick(allPhotos, i)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photo, 'thumbnail')}
|
src={photoUrl(photo, 'thumbnail')}
|
||||||
@@ -1125,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
|
||||||
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1142,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
userId={userId}
|
userId={userId}
|
||||||
entries={entriesWithContent}
|
entries={entriesWithContent}
|
||||||
trips={trips}
|
trips={trips}
|
||||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
onAdd={async (groups, entryId) => {
|
onAdd={async (groups, entryId) => {
|
||||||
let targetId = entryId
|
|
||||||
if (!targetId) {
|
|
||||||
try {
|
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
|
||||||
title: 'Gallery',
|
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
targetId = entry.id
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
let added = 0
|
let added = 0
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
try {
|
try {
|
||||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
if (entryId) {
|
||||||
added += result.added || 0
|
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
} else {
|
||||||
|
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
@@ -1358,7 +1363,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
{entry.location_name && (
|
{entry.location_name && (
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
||||||
<MapPin size={10} className="flex-shrink-0" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name}</span>
|
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1401,7 +1406,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||||
{entry.location_name && (
|
{entry.location_name && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
||||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
|
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1480,7 +1485,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
|
|||||||
{entry.title || t('journey.detail.newEntry')}
|
{entry.title || t('journey.detail.newEntry')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||||
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||||
@@ -1764,11 +1769,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: '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-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<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">
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1778,7 +1783,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1.5 mb-3">
|
<div className="flex gap-1.5 mb-3">
|
||||||
{[
|
{[
|
||||||
@@ -1864,7 +1869,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add-to entry selector */}
|
{/* Add-to entry selector */}
|
||||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<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">
|
<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>
|
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||||
<button
|
<button
|
||||||
@@ -1917,7 +1922,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||||
if (selectable.length === 0) return null
|
if (selectable.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1942,7 +1947,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Photo grid */}
|
{/* Photo grid */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<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 className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||||
@@ -2015,7 +2020,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||||
@@ -2161,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
journeyId: number
|
journeyId: number
|
||||||
tripDates: Set<string>
|
tripDates: Set<string>
|
||||||
galleryPhotos: JourneyPhoto[]
|
galleryPhotos: GalleryPhoto[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||||
@@ -2187,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||||
@@ -2214,6 +2219,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
pendingLinkIds.length > 0
|
pendingLinkIds.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||||
onClose()
|
onClose()
|
||||||
@@ -2323,7 +2330,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
{showGalleryPick && (
|
{showGalleryPick && (
|
||||||
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
{availableGalleryPhotos.map(gp => (
|
||||||
<div
|
<div
|
||||||
key={gp.id}
|
key={gp.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -2343,7 +2350,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<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 }} />
|
<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 }} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
{availableGalleryPhotos.length === 0 && (
|
||||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2378,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
await journeyApi.deletePhoto(p.id)
|
|
||||||
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
||||||
|
if (entry.id > 0) {
|
||||||
|
// unlink from entry; gallery row is preserved
|
||||||
|
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
|
||||||
|
} else {
|
||||||
|
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
@@ -2952,7 +2964,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
onClick={deleteLink}
|
onClick={deleteLink}
|
||||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||||
>
|
>
|
||||||
Remove share link
|
{t('share.deleteLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2960,11 +2972,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||||
journey: JourneyDetail
|
journey: JourneyDetail
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onOpenInvite: () => void
|
onOpenInvite: () => void
|
||||||
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [title, setTitle] = useState(journey.title)
|
const [title, setTitle] = useState(journey.title)
|
||||||
@@ -2972,6 +2985,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
||||||
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||||
|
|
||||||
|
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
|
||||||
|
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
|
||||||
const coverRef = useRef<HTMLInputElement>(null)
|
const coverRef = useRef<HTMLInputElement>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -3030,12 +3047,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: '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-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="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">
|
<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>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</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">
|
<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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3131,7 +3148,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
try {
|
try {
|
||||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||||
toast.success(t('journey.contributors.removed'))
|
toast.success(t('journey.contributors.removed'))
|
||||||
onSaved()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('journey.contributors.removeFailed'))
|
toast.error(t('journey.contributors.removeFailed'))
|
||||||
}
|
}
|
||||||
@@ -3182,7 +3199,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
{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>
|
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} 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={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">
|
<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')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -3229,6 +3246,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
confirmLabel={t('common.delete')}
|
confirmLabel={t('common.delete')}
|
||||||
danger
|
danger
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDiscardConfirm}
|
||||||
|
onClose={() => setShowDiscardConfirm(false)}
|
||||||
|
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
|
||||||
|
title={t('common.discardChanges')}
|
||||||
|
message={t('journey.editor.discardChangesConfirm')}
|
||||||
|
confirmLabel={t('common.discard')}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Journey/MobileMapTimeline', () => ({
|
||||||
|
default: ({ onEntryClick }: any) => (
|
||||||
|
<div data-testid="mobile-map-timeline">
|
||||||
|
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
|
||||||
|
Open Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsMobile = { value: false };
|
||||||
|
vi.mock('../hooks/useIsMobile', () => ({
|
||||||
|
useIsMobile: () => mockIsMobile.value,
|
||||||
|
}));
|
||||||
|
|
||||||
import JourneyPublicPage from './JourneyPublicPage';
|
import JourneyPublicPage from './JourneyPublicPage';
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
@@ -106,6 +121,9 @@ const mockJourneyData = {
|
|||||||
share_gallery: true,
|
share_gallery: true,
|
||||||
share_map: true,
|
share_map: true,
|
||||||
},
|
},
|
||||||
|
gallery: [
|
||||||
|
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
|
||||||
|
],
|
||||||
stats: {
|
stats: {
|
||||||
entries: 2,
|
entries: 2,
|
||||||
photos: 1,
|
photos: 1,
|
||||||
@@ -136,6 +154,7 @@ function setup404() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockIsMobile.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
@@ -234,28 +253,20 @@ describe('JourneyPublicPage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
|
||||||
setupSuccess();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
// Desktop two-column: map sidebar is always rendered alongside the timeline;
|
||||||
const mapBtn = buttons.find(
|
// there is no standalone "Map" tab button on desktop.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
await waitFor(() => {
|
||||||
);
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||||
expect(mapBtn).toBeDefined();
|
});
|
||||||
if (mapBtn) {
|
// Timeline entries remain visible (two-column shows both simultaneously)
|
||||||
fireEvent.click(mapBtn);
|
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||||
// After clicking map tab, the timeline entries should no longer be visible
|
|
||||||
// and the map view content should be rendered (even if JourneyMap errors internally
|
|
||||||
// due to jsdom limitations, the tab state switches)
|
|
||||||
await waitFor(() => {
|
|
||||||
// Shibuya Crossing (timeline-only) should not appear once map is active
|
|
||||||
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
||||||
@@ -303,24 +314,18 @@ describe('JourneyPublicPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// FE-PAGE-PUBLICJOURNEY-012
|
// FE-PAGE-PUBLICJOURNEY-012
|
||||||
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
setupSuccess();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapBtn = screen.getAllByRole('button').find(
|
// Desktop two-column: map sidebar is always rendered; no tab click required.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
|
||||||
);
|
|
||||||
expect(mapBtn).toBeDefined();
|
|
||||||
await user.click(mapBtn!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// Map receives entries with lat/lng
|
// Both fixture entries have coordinates → map receives 2 located entries
|
||||||
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,6 +359,11 @@ describe('JourneyPublicPage', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
gallery: [
|
||||||
|
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
|
||||||
|
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
|
||||||
|
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
|
||||||
|
],
|
||||||
stats: { entries: 1, photos: 3, places: 0 },
|
stats: { entries: 1, photos: 3, places: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,6 +415,40 @@ describe('JourneyPublicPage', () => {
|
|||||||
expect(statsContainer!.textContent).toContain('7');
|
expect(statsContainer!.textContent).toContain('7');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
|
||||||
|
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
|
||||||
|
mockIsMobile.value = true;
|
||||||
|
setupSuccess();
|
||||||
|
render(<JourneyPublicPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
|
||||||
|
expect(mapBtn).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
|
||||||
|
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockIsMobile.value = true;
|
||||||
|
setupSuccess();
|
||||||
|
render(<JourneyPublicPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
|
||||||
|
const openBtn = screen.getByText('Open Entry');
|
||||||
|
await user.click(openBtn);
|
||||||
|
|
||||||
|
// MobileEntryView should slide in with the entry title
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// FE-PAGE-PUBLICJOURNEY-016
|
// FE-PAGE-PUBLICJOURNEY-016
|
||||||
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
import {
|
||||||
|
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
|
||||||
|
Laugh, Smile, Meh, Frown,
|
||||||
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||||
|
ThumbsUp, ThumbsDown,
|
||||||
|
} from 'lucide-react'
|
||||||
import JourneyMap from '../components/Journey/JourneyMap'
|
import JourneyMap from '../components/Journey/JourneyMap'
|
||||||
|
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
|
|
||||||
interface PublicEntry {
|
interface PublicEntry {
|
||||||
id: number
|
id: number
|
||||||
@@ -36,15 +45,42 @@ interface PublicPhoto {
|
|||||||
caption?: string | null
|
caption?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
interface PublicGalleryPhoto {
|
||||||
|
id: number
|
||||||
|
journey_id: number
|
||||||
|
photo_id: number
|
||||||
|
provider?: string
|
||||||
|
asset_id?: string | null
|
||||||
|
owner_id?: number | null
|
||||||
|
file_path?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||||
|
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||||
|
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||||
|
sunny: { icon: Sun, label: 'Sunny' },
|
||||||
|
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||||
|
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||||
|
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||||
|
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||||
|
cold: { icon: Snowflake, label: 'Cold' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||||
const date = new Date(d + 'T00:00:00')
|
const date = new Date(d + 'T00:00:00')
|
||||||
return {
|
return {
|
||||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +106,16 @@ export default function JourneyPublicPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||||
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
|
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
|
||||||
|
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
|
||||||
|
|
||||||
|
const handleMarkerClick = useCallback((entryId: string) => {
|
||||||
|
setActiveEntryId(entryId)
|
||||||
|
mapRef.current?.highlightMarker(entryId)
|
||||||
|
document.querySelector(`[data-entry-id="${entryId}"]`)
|
||||||
|
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -80,25 +126,45 @@ export default function JourneyPublicPage() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const entries = (data?.entries || []) as PublicEntry[]
|
const entries = (data?.entries || []) as PublicEntry[]
|
||||||
|
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
|
||||||
const perms = data?.permissions || {}
|
const perms = data?.permissions || {}
|
||||||
const journey = data?.journey || {}
|
const journey = data?.journey || {}
|
||||||
const stats = data?.stats || {}
|
const stats = data?.stats || {}
|
||||||
|
|
||||||
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
|
const timelineEntries = useMemo(() => entries, [entries])
|
||||||
// produced by the trip→journey sync. They have no story and no
|
|
||||||
// location, and the owner view strips them from the timeline the
|
|
||||||
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
|
|
||||||
const timelineEntries = useMemo(
|
|
||||||
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
|
||||||
[entries],
|
|
||||||
)
|
|
||||||
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
|
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
|
||||||
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||||
const mapEntries = useMemo(
|
const mapEntries = useMemo(
|
||||||
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
||||||
[timelineEntries],
|
[timelineEntries],
|
||||||
)
|
)
|
||||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
const allPhotos = gallery
|
||||||
|
|
||||||
|
// Map entries with day color/label for colored markers.
|
||||||
|
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
|
||||||
|
// stay in sync with the timeline day headers even when some days have no locations.
|
||||||
|
const sidebarMapItems = useMemo(() => {
|
||||||
|
const counters = new Map<string, number>()
|
||||||
|
return mapEntries.map(e => {
|
||||||
|
const dayIdx = sortedDates.indexOf(e.entry_date)
|
||||||
|
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||||
|
counters.set(e.entry_date, dayLabel)
|
||||||
|
return {
|
||||||
|
id: String(e.id),
|
||||||
|
lat: e.location_lat!,
|
||||||
|
lng: e.location_lng!,
|
||||||
|
title: e.title || '',
|
||||||
|
mood: e.mood,
|
||||||
|
created_at: e.entry_date,
|
||||||
|
entry_date: e.entry_date,
|
||||||
|
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||||
|
dayLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [mapEntries, sortedDates])
|
||||||
|
|
||||||
|
// Two-column desktop layout: timeline feed left + sticky map right
|
||||||
|
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
|
||||||
|
|
||||||
// Set default view based on permissions
|
// Set default view based on permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,6 +172,11 @@ export default function JourneyPublicPage() {
|
|||||||
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
||||||
}, [perms])
|
}, [perms])
|
||||||
|
|
||||||
|
// When switching to desktop two-column, 'map' standalone tab no longer exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (desktopTwoColumn && view === 'map') setView('timeline')
|
||||||
|
}, [desktopTwoColumn, view])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||||
@@ -125,21 +196,262 @@ export default function JourneyPublicPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||||
const availableViews = [
|
const availableViews = [
|
||||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||||
|
|
||||||
|
// Shared timeline renderer used in both layout modes
|
||||||
|
const renderTimeline = () => (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{sortedDates.length === 0 && (
|
||||||
|
<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">
|
||||||
|
<BookOpen size={24} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortedDates.map((date, dayIdx) => {
|
||||||
|
const dayEntries = groupedEntries.get(date)!
|
||||||
|
const fd = formatDate(date, locale)
|
||||||
|
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
|
||||||
|
return (
|
||||||
|
<div key={date}>
|
||||||
|
{/* Day header */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
|
||||||
|
style={{ background: dayColor }}
|
||||||
|
>
|
||||||
|
{dayIdx + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entries */}
|
||||||
|
<div className="flex flex-col gap-4 pl-[52px]">
|
||||||
|
{dayEntries.map(entry => {
|
||||||
|
const photos = entry.photos || []
|
||||||
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||||
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||||
|
const prosArr = entry.pros_cons?.pros ?? []
|
||||||
|
const consArr = entry.pros_cons?.cons ?? []
|
||||||
|
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||||
|
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
|
||||||
|
|
||||||
|
const isActive = activeEntryId === String(entry.id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
data-entry-id={String(entry.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!desktopTwoColumn) return
|
||||||
|
setActiveEntryId(String(entry.id))
|
||||||
|
mapRef.current?.highlightMarker(String(entry.id))
|
||||||
|
}}
|
||||||
|
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
|
||||||
|
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||||
|
|
||||||
|
{/* Photo area */}
|
||||||
|
{photos.length === 1 && (
|
||||||
|
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||||
|
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
|
||||||
|
{entry.location_name && (
|
||||||
|
<div className="absolute top-3 left-4">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
|
||||||
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
|
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.title && (
|
||||||
|
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
|
||||||
|
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photos.length === 2 && (
|
||||||
|
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
||||||
|
{photos.slice(0, 2).map((p, i) => (
|
||||||
|
<img
|
||||||
|
key={p.id}
|
||||||
|
src={photoUrl(p, token!, 'thumbnail')}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-52 object-cover cursor-pointer"
|
||||||
|
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photos.length >= 3 && (
|
||||||
|
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
|
||||||
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||||
|
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||||
|
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
|
||||||
|
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
|
||||||
|
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||||
|
{photos.length > 3 && (
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
|
||||||
|
<Image size={13} /> +{photos.length - 3}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
|
||||||
|
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||||
|
{photos.length !== 1 && entry.title && (
|
||||||
|
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location + time badges */}
|
||||||
|
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{entry.location_name && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
|
||||||
|
<MapPin size={11} className="flex-shrink-0" />
|
||||||
|
{formatLocationName(entry.location_name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.entry_time && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
|
||||||
|
<Clock size={11} />
|
||||||
|
{entry.entry_time.slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.entry_time && photos.length === 1 && (
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
|
||||||
|
<Clock size={11} />
|
||||||
|
{entry.entry_time.slice(0, 5)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story */}
|
||||||
|
{entry.story && (
|
||||||
|
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||||
|
<JournalBody text={entry.story} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pros & Cons */}
|
||||||
|
{hasProscons && (
|
||||||
|
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
|
{prosArr.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
|
||||||
|
<ThumbsUp size={10} /> Pros
|
||||||
|
</div>
|
||||||
|
{prosArr.map((p, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||||
|
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{consArr.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
|
||||||
|
<ThumbsDown size={10} /> Cons
|
||||||
|
</div>
|
||||||
|
{consArr.map((c, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||||
|
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mood + weather */}
|
||||||
|
{(mood || weather) && (
|
||||||
|
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
{mood && (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
|
||||||
|
<mood.icon size={11} /> {mood.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{weather && (
|
||||||
|
<span className="inline-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">
|
||||||
|
<weather.icon size={11} /> {weather.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared gallery renderer
|
||||||
|
const renderGallery = () => (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||||
|
{allPhotos.map((photo, idx) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||||
|
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||||
|
>
|
||||||
|
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared view tab bar
|
||||||
|
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
|
||||||
|
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||||
|
{views.map(v => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => setView(v.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||||
|
view === v.id
|
||||||
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<v.icon size={13} />
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
|
||||||
{/* Cover image background */}
|
|
||||||
{journey.cover_image && (
|
{journey.cover_image && (
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||||
)}
|
)}
|
||||||
{/* Decorative circles */}
|
|
||||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||||
|
|
||||||
@@ -194,160 +506,98 @@ export default function JourneyPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
{desktopTwoColumn ? (
|
||||||
|
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||||
{/* View tabs */}
|
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||||
{availableViews.length > 1 && (
|
{/* Left: feed */}
|
||||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
|
||||||
{availableViews.map(v => (
|
{renderTabs(availableViews)}
|
||||||
<button
|
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||||
key={v.id}
|
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||||
onClick={() => setView(v.id)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
|
||||||
view === v.id
|
|
||||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
|
||||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<v.icon size={13} />
|
|
||||||
{v.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile combined map+timeline (public, read-only) */}
|
{/* Right: sticky map — matches auth page aside proportions */}
|
||||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
<aside
|
||||||
<MobileMapTimeline
|
className="flex-shrink-0"
|
||||||
entries={timelineEntries}
|
style={{
|
||||||
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
|
width: '44%', minWidth: 420, maxWidth: 760,
|
||||||
dark={document.documentElement.classList.contains('dark')}
|
position: 'sticky', top: 0, height: '100dvh',
|
||||||
readOnly
|
padding: '16px 16px 16px 0',
|
||||||
onEntryClick={() => {}}
|
alignSelf: 'flex-start',
|
||||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||||
|
<JourneyMap
|
||||||
|
ref={mapRef}
|
||||||
|
checkins={[]}
|
||||||
|
entries={sidebarMapItems as any}
|
||||||
|
height={9999}
|
||||||
|
fullScreen
|
||||||
|
activeMarkerId={activeEntryId ?? undefined}
|
||||||
|
onMarkerClick={handleMarkerClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// ── Single-column layout (mobile + desktop-without-map) ───────────────
|
||||||
|
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||||
|
|
||||||
{/* Timeline (desktop, or mobile without map permission) */}
|
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||||
{sortedDates.map(date => {
|
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||||
const dayEntries = groupedEntries.get(date)!
|
{availableViews.map(v => (
|
||||||
const fd = formatDate(date)
|
<button
|
||||||
return (
|
key={v.id}
|
||||||
<div key={date}>
|
onClick={() => setView(v.id)}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||||
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
|
view === v.id
|
||||||
<div>
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<v.icon size={13} />
|
||||||
<div className="flex flex-col gap-4 pl-[52px]">
|
{v.label}
|
||||||
{dayEntries.map(entry => (
|
</button>
|
||||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
))}
|
||||||
{entry.photos.length > 0 && (
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={photoUrl(entry.photos[0], token!)}
|
|
||||||
className="w-full h-52 object-cover cursor-pointer"
|
|
||||||
alt=""
|
|
||||||
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
|
|
||||||
/>
|
|
||||||
{entry.photos.length > 1 && (
|
|
||||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
|
||||||
<Image size={10} /> +{entry.photos.length - 1}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.title && (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
|
|
||||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
{!entry.photos.length && entry.title && (
|
|
||||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
|
|
||||||
)}
|
|
||||||
{entry.location_name && (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
|
|
||||||
<MapPin size={11} /> {entry.location_name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.story && (
|
|
||||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
|
||||||
<JournalBody text={entry.story} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
||||||
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
|
|
||||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
|
|
||||||
{entry.pros_cons.pros!.map((p, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
|
|
||||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
|
|
||||||
{entry.pros_cons.cons!.map((c, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gallery */}
|
|
||||||
{view === 'gallery' && perms.share_gallery && (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
|
||||||
{allPhotos.map(({ photo }, idx) => (
|
|
||||||
<div
|
|
||||||
key={photo.id}
|
|
||||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
|
|
||||||
>
|
|
||||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Map */}
|
{renderTabs(availableViews)}
|
||||||
{view === 'map' && perms.share_map && (
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
{/* Mobile combined map+timeline (public, read-only) */}
|
||||||
<JourneyMap
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||||
checkins={[]}
|
<MobileMapTimeline
|
||||||
entries={mapEntries.map(e => ({
|
entries={timelineEntries}
|
||||||
id: String(e.id),
|
mapEntries={sidebarMapItems as any}
|
||||||
lat: e.location_lat!,
|
dark={document.documentElement.classList.contains('dark')}
|
||||||
lng: e.location_lng!,
|
readOnly
|
||||||
title: e.title || '',
|
onEntryClick={(entry) => setViewingEntry(entry as any)}
|
||||||
mood: e.mood,
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
created_at: e.entry_date,
|
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||||
entry_date: e.entry_date,
|
|
||||||
})) as any}
|
|
||||||
height={500}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{/* Timeline (desktop, or mobile without map permission) */}
|
||||||
|
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||||
|
|
||||||
|
{/* Map (standalone tab — only in single-column mode) */}
|
||||||
|
{view === 'map' && perms.share_map && (
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||||
|
<JourneyMap
|
||||||
|
checkins={[]}
|
||||||
|
entries={sidebarMapItems as any}
|
||||||
|
height={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Powered by */}
|
{/* Powered by */}
|
||||||
<div className="flex flex-col items-center py-8 gap-2">
|
<div className="flex flex-col items-center py-8 gap-2">
|
||||||
@@ -368,6 +618,26 @@ export default function JourneyPublicPage() {
|
|||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile entry detail view (public share) */}
|
||||||
|
{viewingEntry && (
|
||||||
|
<MobileEntryView
|
||||||
|
entry={viewingEntry as any}
|
||||||
|
readOnly
|
||||||
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
|
onClose={() => setViewingEntry(null)}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
onPhotoClick={(photos, idx) => setLightbox({
|
||||||
|
photos: photos.map(p => ({
|
||||||
|
id: String(p.id),
|
||||||
|
src: photoUrl(p as any, token!, 'original'),
|
||||||
|
caption: (p as any).caption ?? null,
|
||||||
|
})),
|
||||||
|
index: idx,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) tripActions.loadReservations(tripId)
|
if (tripId) {
|
||||||
|
tripActions.loadReservations(tripId)
|
||||||
|
tripActions.loadBudgetItems?.(tripId)
|
||||||
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
@@ -642,6 +645,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
|
setEditingReservation(null)
|
||||||
if (data.type === 'hotel') {
|
if (data.type === 'hotel') {
|
||||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -1105,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
|||||||
expect(useJourneyStore.getState().loading).toBe(false);
|
expect(useJourneyStore.getState().loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||||
|
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||||
|
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||||
|
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([202, 201, 203]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||||
|
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||||
|
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||||
|
);
|
||||||
|
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([211, 212]);
|
||||||
|
});
|
||||||
|
|
||||||
// ── clear ────────────────────────────────────────────────────────────────
|
// ── clear ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
|
|||||||
height?: number | null
|
height?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GalleryPhoto {
|
||||||
|
id: number
|
||||||
|
journey_id: number
|
||||||
|
photo_id: number
|
||||||
|
caption?: string | null
|
||||||
|
shared: number
|
||||||
|
sort_order: number
|
||||||
|
created_at: number
|
||||||
|
// Joined from trek_photos for display
|
||||||
|
provider?: string
|
||||||
|
asset_id?: string | null
|
||||||
|
owner_id?: number | null
|
||||||
|
file_path?: string | null
|
||||||
|
thumbnail_path?: string | null
|
||||||
|
width?: number | null
|
||||||
|
height?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface JourneyTrip {
|
export interface JourneyTrip {
|
||||||
trip_id: number
|
trip_id: number
|
||||||
added_at: number
|
added_at: number
|
||||||
@@ -79,6 +97,7 @@ export interface JourneyContributor {
|
|||||||
|
|
||||||
export interface JourneyDetail extends Journey {
|
export interface JourneyDetail extends Journey {
|
||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
|
gallery: GalleryPhoto[]
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
contributors: JourneyContributor[]
|
contributors: JourneyContributor[]
|
||||||
stats: { entries: number; photos: number; places: number }
|
stats: { entries: number; photos: number; places: number }
|
||||||
@@ -103,6 +122,9 @@ interface JourneyState {
|
|||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||||
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
|
|
||||||
clear: () => void
|
clear: () => void
|
||||||
@@ -201,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||||
const atime = a.entry_time || ''
|
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||||
const btime = b.entry_time || ''
|
return a.id - b.id
|
||||||
if (atime !== btime) return atime.localeCompare(btime)
|
|
||||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
|
||||||
})
|
})
|
||||||
return { current: { ...s.current, entries } }
|
return { current: { ...s.current, entries } }
|
||||||
})
|
})
|
||||||
@@ -228,12 +248,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
entries: s.current.entries.map(e =>
|
entries: s.current.entries.map(e =>
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
),
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return photos
|
return photos
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||||
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||||
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
|
||||||
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
|
||||||
|
set(s => {
|
||||||
|
if (!s.current) return s
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
...s.current,
|
||||||
|
entries: s.current.entries.map(e =>
|
||||||
|
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
|
||||||
|
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
|
||||||
|
set(s => {
|
||||||
|
if (!s.current) return s
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
...s.current,
|
||||||
|
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
|
||||||
|
entries: s.current.entries.map(e => ({
|
||||||
|
...e,
|
||||||
|
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
deletePhoto: async (photoId) => {
|
deletePhoto: async (photoId) => {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await journeyApi.deletePhoto(photoId)
|
||||||
set(s => {
|
set(s => {
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export interface Reservation {
|
|||||||
place_id?: number | null
|
place_id?: number | null
|
||||||
assignment_id?: number | null
|
assignment_id?: number | null
|
||||||
accommodation_id?: number | null
|
accommodation_id?: number | null
|
||||||
|
accommodation_start_day_id?: number | null
|
||||||
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
|||||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navigator.standalone is true only on iOS when running as an
|
||||||
|
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||||
|
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||||
|
function isIosStandalone(): boolean {
|
||||||
|
return (navigator as any).standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a protected file using cookie auth (credentials: include) and
|
* Fetches a protected file using cookie auth (credentials: include) and
|
||||||
* triggers a browser download. Works inside PWA standalone mode because the
|
* triggers a browser download. Works inside PWA standalone mode because the
|
||||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
|||||||
* (including text/html and image/svg+xml which can execute script) are forced
|
* (including text/html and image/svg+xml which can execute script) are forced
|
||||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||||
*
|
*
|
||||||
* Falls back to a download trigger if the popup is blocked.
|
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||||
|
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||||
|
* window feature returns null per spec, which previously made the popup-block
|
||||||
|
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||||
|
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||||
|
* the new tab is opened by the browser's normal link-handling path, and no
|
||||||
|
* spurious in-page download is triggered.
|
||||||
*/
|
*/
|
||||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
assertRelativeUrl(url)
|
||||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||||
if (win) {
|
if (isIosStandalone()) {
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
|
||||||
} else {
|
|
||||||
// Popup blocked — fall back to download
|
|
||||||
triggerAnchorDownload(blobUrl, filename)
|
triggerAnchorDownload(blobUrl, filename)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = blobUrl
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener noreferrer'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||||
|
// clean up the DOM node and revoke the URL.
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { AssignmentsMap } from '../types'
|
import type { AssignmentsMap } from '../types'
|
||||||
|
|
||||||
|
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
|
||||||
|
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
|
||||||
|
// Clean short names (≤3 parts) pass through untouched.
|
||||||
|
export function formatLocationName(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return ''
|
||||||
|
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
|
||||||
|
if (parts.length <= 3) return raw.trim()
|
||||||
|
|
||||||
|
// Dedup preserving insertion order
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const unique: string[] = []
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
|
||||||
|
}
|
||||||
|
if (unique.length <= 3) return unique.join(', ')
|
||||||
|
|
||||||
|
const name = unique[0]
|
||||||
|
const last = unique[unique.length - 1]
|
||||||
|
const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null
|
||||||
|
|
||||||
|
// Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
|
||||||
|
const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
|
||||||
|
const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
|
||||||
|
const postcode = isLastPostal ? last : null
|
||||||
|
const country = isLastPostal ? secondLast : last
|
||||||
|
|
||||||
|
const result: string[] = [name]
|
||||||
|
if (postcode && postcode !== name) result.push(postcode)
|
||||||
|
if (country && country !== name && country !== postcode) result.push(country)
|
||||||
|
|
||||||
|
return result.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
|
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
|
||||||
|
|
||||||
export function currencyDecimals(currency: string): number {
|
export function currencyDecimals(currency: string): number {
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
|
|||||||
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||||
|
|
||||||
// ResizeObserver — used by resizable panels
|
// ResizeObserver — used by resizable panels
|
||||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
class _MockResizeObserver {
|
||||||
observe: vi.fn(),
|
observe = vi.fn()
|
||||||
unobserve: vi.fn(),
|
unobserve = vi.fn()
|
||||||
disconnect: vi.fn(),
|
disconnect = vi.fn()
|
||||||
})) as unknown as typeof ResizeObserver;
|
constructor(_callback: ResizeObserverCallback) {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
||||||
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
||||||
|
|||||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('openFile', () => {
|
describe('openFile', () => {
|
||||||
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
const mockWin = { closed: false }
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/doc.pdf')
|
await openFile('/uploads/files/doc.pdf')
|
||||||
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||||
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
// Must NOT call window.open — that path returns null when noreferrer is
|
||||||
|
// set, which previously caused the file to also open in the current tab.
|
||||||
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// The anchor used to open the new tab must be target=_blank, must NOT
|
||||||
|
// carry a `download` attribute (otherwise it would download in-page
|
||||||
|
// instead of opening), and must use rel=noopener noreferrer.
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.target).toBe('_blank')
|
||||||
|
expect(anchor.rel).toBe('noopener noreferrer')
|
||||||
|
expect(anchor.hasAttribute('download')).toBe(false)
|
||||||
|
|
||||||
// Revoke happens after 30s timeout
|
// Revoke happens after 30s timeout
|
||||||
vi.runAllTimers()
|
vi.runAllTimers()
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to anchor download when popup is blocked', async () => {
|
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/doc.pdf')
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||||
|
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||||
vi.runAllTimers()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on 401 response', async () => {
|
it('throws on 401 response', async () => {
|
||||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
|||||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
||||||
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/malicious.html')
|
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
||||||
|
|
||||||
// Must NOT open inline — download anchor clicked instead
|
// Must NOT open inline — download anchor clicked instead
|
||||||
expect(openSpy).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.download).toBe('malicious.html')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forces download for SVG MIME type', async () => {
|
it('forces download for SVG MIME type', async () => {
|
||||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
||||||
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/malicious.svg')
|
await openFile('/uploads/files/malicious.svg')
|
||||||
|
|
||||||
expect(window.open).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
// Simulate iOS PWA (Add-to-Home-Screen) context
|
||||||
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||||
|
|
||||||
|
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
||||||
|
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
||||||
|
// read the in-WebView blob URL.
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.target).toBe('')
|
||||||
|
expect(anchor.download).toBe('doc.pdf')
|
||||||
|
} finally {
|
||||||
|
// Clean up the non-standard iOS-only property we forced above.
|
||||||
|
delete (navigator as any).standalone
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
# - 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. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - 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. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
|
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11956
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "server-nest-2",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/oauth-provider": "^1.6.9",
|
||||||
|
"@better-auth/passkey": "^1.6.9",
|
||||||
|
"@hedystia/better-auth-typeorm": "^0.8.2",
|
||||||
|
"@nestjs/common": "^11.1.19",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.1.19",
|
||||||
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@nestjs/websockets": "^11.1.19",
|
||||||
|
"@thallesp/nestjs-better-auth": "^2.6.0",
|
||||||
|
"better-auth": "^1.6.9",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"csrf-csrf": "^4.0.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"mysql2": "^3.22.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"typeorm": "^0.3.28"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import authConfig from './config/auth.config.js';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
|
|
||||||
|
type SupportedDbType = 'mysql' | 'mariadb' | 'postgres' | 'sqlite';
|
||||||
|
|
||||||
|
function resolveDriver(type: SupportedDbType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'mysql':
|
||||||
|
case 'mariadb':
|
||||||
|
return require('mysql2');
|
||||||
|
case 'postgres':
|
||||||
|
return require('pg');
|
||||||
|
case 'sqlite':
|
||||||
|
return require('better-sqlite3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, load: [authConfig] }),
|
||||||
|
AuthModule,
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
const type = config.get<SupportedDbType>('DB_TYPE', 'sqlite');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
driver: resolveDriver(type),
|
||||||
|
host: config.get<string>('DB_HOST', 'localhost'),
|
||||||
|
port: config.get<number>('DB_PORT', 5432),
|
||||||
|
username: config.get<string>('DB_USER', 'usr'),
|
||||||
|
password: config.get<string>('DB_PASS', 'pwd'),
|
||||||
|
database: config.get<string>('DB_NAME', 'data/travel.db'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: config.get<string>('NODE_ENV') !== 'production',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { typeormAdapter } from '@hedystia/better-auth-typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuthConfig } from '../config/auth.config.js';
|
||||||
|
|
||||||
|
export function createAuth(dataSource: DataSource, cfg: AuthConfig) {
|
||||||
|
return betterAuth({
|
||||||
|
database: typeormAdapter(dataSource, { debugLogs: cfg.debugLogs }),
|
||||||
|
secret: cfg.secret,
|
||||||
|
baseURL: cfg.baseURL,
|
||||||
|
basePath: '/api/auth',
|
||||||
|
trustedOrigins: cfg.frontendUrl ? [cfg.frontendUrl] : [],
|
||||||
|
advanced: {
|
||||||
|
cookies: { session_token: { name: 'trek_session' } },
|
||||||
|
useSecureCookies: cfg.cookieSecure,
|
||||||
|
},
|
||||||
|
emailAndPassword: { enabled: true },
|
||||||
|
plugins: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { typeormAdapter } from '@hedystia/better-auth-typeorm';
|
||||||
|
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||||
|
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
||||||
|
import { jwt } from 'better-auth/plugins/jwt';
|
||||||
|
import { oauthProvider } from '@better-auth/oauth-provider';
|
||||||
|
import { passkey } from '@better-auth/passkey';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
// Used only by `npx @better-auth/cli generate`.
|
||||||
|
// Not imported at runtime — auth.factory.ts uses the DI DataSource.
|
||||||
|
const dataSource = new DataSource({
|
||||||
|
type: 'better-sqlite3',
|
||||||
|
database: ':memory:',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
database: typeormAdapter(dataSource, {
|
||||||
|
entitiesDir: './src/models/entities/auth',
|
||||||
|
migrationsDir: './src/database/migrations',
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: true,
|
||||||
|
sendVerificationEmail: async () => {},
|
||||||
|
sendResetPassword: async () => {},
|
||||||
|
},
|
||||||
|
emailVerification: {
|
||||||
|
sendVerificationEmail: async () => {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
jwt(),
|
||||||
|
magicLink({ sendMagicLink: async () => {} }),
|
||||||
|
genericOAuth({ config: [] }),
|
||||||
|
oauthProvider({ loginPage: '/login' }),
|
||||||
|
passkey(),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthModule as BetterAuthNestModule } from '@thallesp/nestjs-better-auth';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { createAuth } from './auth.factory.js';
|
||||||
|
import { AuthConfig } from '../config/auth.config.js';
|
||||||
|
import { User } from '../models/entities/auth/User.js';
|
||||||
|
import { Account } from '../models/entities/auth/Account.js';
|
||||||
|
import { Session } from '../models/entities/auth/Session.js';
|
||||||
|
import { Verification } from '../models/entities/auth/Verification.js';
|
||||||
|
import { Passkey } from '../models/entities/auth/Passkey.js';
|
||||||
|
import { Jwks } from '../models/entities/auth/Jwks.js';
|
||||||
|
import { OauthClient } from '../models/entities/auth/OauthClient.js';
|
||||||
|
import { OauthAccessToken } from '../models/entities/auth/OauthAccessToken.js';
|
||||||
|
import { OauthRefreshToken } from '../models/entities/auth/OauthRefreshToken.js';
|
||||||
|
import { OauthConsent } from '../models/entities/auth/OauthConsent.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
User,
|
||||||
|
Account,
|
||||||
|
Session,
|
||||||
|
Verification,
|
||||||
|
Passkey,
|
||||||
|
Jwks,
|
||||||
|
OauthClient,
|
||||||
|
OauthAccessToken,
|
||||||
|
OauthRefreshToken,
|
||||||
|
OauthConsent,
|
||||||
|
]),
|
||||||
|
BetterAuthNestModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [DataSource, ConfigService],
|
||||||
|
useFactory: (ds: DataSource, config: ConfigService) => ({
|
||||||
|
auth: createAuth(ds, config.get<AuthConfig>('auth')!),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [BetterAuthNestModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
import { boolean } from 'better-auth';
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
secret: string;
|
||||||
|
baseURL: string;
|
||||||
|
frontendUrl: string | undefined;
|
||||||
|
cookieSecure: boolean;
|
||||||
|
debugLogs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerAs(
|
||||||
|
'auth',
|
||||||
|
(): AuthConfig => ({
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET ?? 'changeme',
|
||||||
|
baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
|
||||||
|
frontendUrl: process.env.BASE_URL,
|
||||||
|
cookieSecure:
|
||||||
|
process.env.COOKIE_SECURE === 'true' &&
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.BASE_URL?.startsWith('https') === true,
|
||||||
|
debugLogs:
|
||||||
|
process.env.BETTER_AUTH_DEBUG_LOGS === 'true' ||
|
||||||
|
process.env.NODE_ENV === 'development',
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAccount1777216318138 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'account',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accountId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'providerId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scope',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'account',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'account_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'account',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('account');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateSession1777216318138 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'session',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'token',
|
||||||
|
type: 'text',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ipAddress',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userAgent',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'session',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'session_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'session',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('session');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateUser1777216318138 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'user',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
type: 'text',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'emailVerified',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateVerification1777216318138 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'verification',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'identifier',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'verification',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'verification_identifier_idx',
|
||||||
|
columnNames: ['identifier'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('verification');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAccount1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'account',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accountId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'providerId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scope',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'account',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'account_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'account',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('account');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateOauthAccessToken1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'oauthAccessToken',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clientId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scopes',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'oauthAccessToken',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'oauthAccessToken_clientId_idx',
|
||||||
|
columnNames: ['clientId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'oauthAccessToken',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['clientId'],
|
||||||
|
referencedTableName: 'oauthApplication',
|
||||||
|
referencedColumnNames: ['clientId'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'oauthAccessToken',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'oauthAccessToken_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'oauthAccessToken',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('oauthAccessToken');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateOauthApplication1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'oauthApplication',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'icon',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clientId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clientSecret',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'redirectUrls',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disabled',
|
||||||
|
type: 'boolean',
|
||||||
|
isNullable: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'oauthApplication',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'oauthApplication_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'oauthApplication',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('oauthApplication');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateOauthConsent1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'oauthConsent',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clientId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scopes',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'consentGiven',
|
||||||
|
type: 'boolean',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'oauthConsent',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'oauthConsent_clientId_idx',
|
||||||
|
columnNames: ['clientId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'oauthConsent',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['clientId'],
|
||||||
|
referencedTableName: 'oauthApplication',
|
||||||
|
referencedColumnNames: ['clientId'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'oauthConsent',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'oauthConsent_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'oauthConsent',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('oauthConsent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreatePasskey1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'passkey',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'publicKey',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'credentialID',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'counter',
|
||||||
|
type: 'integer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deviceType',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backedUp',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'transports',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'aaguid',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'passkey',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'passkey_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'passkey',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'passkey',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'passkey_credentialID_idx',
|
||||||
|
columnNames: ['credentialID'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('passkey');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateSession1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'session',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'token',
|
||||||
|
type: 'text',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ipAddress',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userAgent',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'session',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'session_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'session',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('session');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateUser1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'user',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
type: 'text',
|
||||||
|
isUnique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'emailVerified',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateVerification1777217712285 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'verification',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'identifier',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'verification',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'verification_identifier_idx',
|
||||||
|
columnNames: ['identifier'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('verification');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
type MigrationInterface,
|
||||||
|
type QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAccount1777217820713 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'account',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accountId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'providerId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idToken',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accessTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refreshTokenExpiresAt',
|
||||||
|
type: 'datetime',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scope',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'datetime',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedAt',
|
||||||
|
type: 'datetime',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'account',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'account_userId_idx',
|
||||||
|
columnNames: ['userId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'account',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['userId'],
|
||||||
|
referencedTableName: 'user',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('account');
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user