mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74be63555d | |||
| fd6fc9e71f | |||
| 22f5623adb | |||
| 6117b80575 | |||
| d98eaaebee | |||
| 45d410c1b0 | |||
| cd36fba0c9 | |||
| f93efe9740 | |||
| 53b1c8617e | |||
| bf7412d016 | |||
| 9b0755debc | |||
| c582a7b6c8 | |||
| 1a5c8cd385 | |||
| 98f90adb6d | |||
| 0935143001 | |||
| c3535967ee | |||
| 4d9854062c | |||
| 173d6cd953 | |||
| da79059576 | |||
| f856956428 | |||
| 4e33d710ea | |||
| c2bb9a904c | |||
| aeac66baff | |||
| 9a8c24cf7b | |||
| e8acbbd129 | |||
| f8dcce802e | |||
| 5d73beca8f | |||
| 74f19f3312 | |||
| f000943489 | |||
| 5b1a9d6e3b | |||
| 2cad77c8fb | |||
| 393f77850b | |||
| b54422a33f | |||
| 8abebdf17f | |||
| 3b683519c9 | |||
| 8601370a47 | |||
| c6a9b54662 | |||
| 0b922ab1a6 | |||
| 10cf482d9f | |||
| cb1e217bbe | |||
| ad36d1ba7c |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
client/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
*.md
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: mauriceboe/nomad:latest
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
server/uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Stage 1: React Client bauen
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Produktions-Server
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Server-Dependencies installieren
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Server-Code kopieren
|
||||
COPY server/ ./
|
||||
|
||||
# Gebauten Client kopieren
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
|
||||
# Fonts für PDF-Export kopieren
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
# Verzeichnisse erstellen
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--experimental-sqlite", "src/index.js"]
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,199 @@
|
||||
# NOMAD
|
||||
|
||||
**Navigation Organizer for Maps, Activities & Destinations**
|
||||
|
||||
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/mauriceboe/nomad)
|
||||
[](https://github.com/mauriceboe/NOMAD)
|
||||
[](https://github.com/mauriceboe/NOMAD/commits)
|
||||
|
||||
**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly.
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>More Screenshots</summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users
|
||||
- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources
|
||||
- **Google Places Integration** — Search places, auto-fill details including ratings, reviews, opening hours, and photos (requires API key)
|
||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key)
|
||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||
- **PDF Export** — Export complete trip plans as PDF with images and notes
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Admin Panel** — User management, create users, global categories, API key configuration, and backups
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Day Notes** — Add timestamped notes to individual days
|
||||
- **Dark Mode** — Full light and dark theme support
|
||||
- **Multilingual** — English and German (i18n)
|
||||
- **Mobile Friendly** — Responsive design with touch-optimized controls
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: OpenWeatherMap API (optional)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/nomad:latest
|
||||
container_name: nomad
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/nomad
|
||||
docker rm -f nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
```
|
||||
|
||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes.
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
|
||||
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name nomad.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name nomad.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
nomad.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||
|
||||
### Google Maps (Place Search & Photos)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project and enable the **Places API (New)**
|
||||
3. Create an API key under Credentials
|
||||
4. In NOMAD: Admin Panel → Settings → Google Maps
|
||||
|
||||
### OpenWeatherMap (Weather Forecasts)
|
||||
|
||||
1. Sign up at [OpenWeatherMap](https://openweathermap.org/api)
|
||||
2. Get a free API key
|
||||
3. In NOMAD: Admin Panel → Settings → OpenWeatherMap
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/NOMAD.git
|
||||
cd NOMAD
|
||||
docker build -t nomad .
|
||||
```
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---|---|
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
Only the latest version receives security updates. Please update to the latest release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue
|
||||
2. Email: **mauriceboe@icloud.com**
|
||||
3. Include a description of the vulnerability and steps to reproduce
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>NOMAD</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3700
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
// PhotosPage removed - replaced by Finanzplan
|
||||
import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
import { authApi } from './api/client'
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }) {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
<p className="text-slate-500 text-sm">Wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user?.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Apply dark mode class to <html>
|
||||
useEffect(() => {
|
||||
if (settings.dark_mode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}, [settings.dark_mode])
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
<ToastContainer />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TripPlannerPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id/files"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FilesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminRequired>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import axios from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token')
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
login: (data) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
updateApiKeys: (data) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||
updateSettings: (data) => apiClient.put('/auth/me/settings', data).then(r => r.data),
|
||||
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
|
||||
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
|
||||
uploadAvatar: (formData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
|
||||
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
|
||||
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
update: (id, data) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id, formData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
archive: (id) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id, identifier) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id, userId) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId, data) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId, dayId, data) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId, dayId) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId, params) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId, data) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
list: (tripId) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId, data) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId, orderedIds) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id, data) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
delete: (id) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => apiClient.get('/categories').then(r => r.data),
|
||||
create: (data) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id, data) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
delete: (id) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
users: () => apiClient.get('/admin/users').then(r => r.data),
|
||||
createUser: (data) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query, lang) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
details: (placeId, lang) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
list: (tripId) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
|
||||
upload: (tripId, formData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}).then(r => r.data),
|
||||
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
list: (tripId) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||
create: (tripId, data) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key, value) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const backupApi = {
|
||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||
download: async (filename) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
delete: (filename) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
||||
restore: (filename) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
||||
uploadRestore: (file) => {
|
||||
const form = new FormData()
|
||||
form.append('backup', file)
|
||||
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
|
||||
setAutoSettings: (settings) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,143 @@
|
||||
// Singleton WebSocket manager for real-time collaboration
|
||||
|
||||
let socket = null
|
||||
let reconnectTimer = null
|
||||
let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set()
|
||||
const activeTrips = new Set()
|
||||
let currentToken = null
|
||||
let refetchCallback = null
|
||||
let mySocketId = null
|
||||
|
||||
export function getSocketId() {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn) {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token) {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data)
|
||||
// Store our socket ID from welcome message
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
console.log('[WS] Got socketId:', mySocketId)
|
||||
return
|
||||
}
|
||||
console.log('[WS] Received:', parsed.type, parsed)
|
||||
listeners.forEach(fn => {
|
||||
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('WebSocket message parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (currentToken) {
|
||||
connectInternal(currentToken, true)
|
||||
}
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token, isReconnect = false) {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(token)
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
|
||||
reconnectDelay = 1000
|
||||
// Join active trips on any connect (initial or reconnect)
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||
console.log('[WS] Joined trip', tripId)
|
||||
}
|
||||
})
|
||||
// Refetch trip data for active trips
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback(tripId) } catch (err) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = handleMessage
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
if (currentToken) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
// onclose will fire after onerror, reconnect handled there
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token) {
|
||||
currentToken = token
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connectInternal(token, false)
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
currentToken = null
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null // prevent reconnect
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
export function joinTrip(tripId) {
|
||||
activeTrips.add(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function leaveTrip(tripId) {
|
||||
activeTrips.delete(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function addListener(fn) {
|
||||
listeners.add(fn)
|
||||
}
|
||||
|
||||
export function removeListener(fn) {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
{ value: 'daily', labelKey: 'backup.interval.daily' },
|
||||
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
|
||||
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
|
||||
]
|
||||
|
||||
const KEEP_OPTIONS = [
|
||||
{ value: 1, labelKey: 'backup.keep.1day' },
|
||||
{ value: 3, labelKey: 'backup.keep.3days' },
|
||||
{ value: 7, labelKey: 'backup.keep.7days' },
|
||||
{ value: 14, labelKey: 'backup.keep.14days' },
|
||||
{ value: 30, labelKey: 'backup.keep.30days' },
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
]
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await backupApi.list()
|
||||
setBackups(data.backups || [])
|
||||
} catch {
|
||||
toast.error(t('backup.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAutoSettings = async () => {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings()
|
||||
setAutoSettings(data.settings)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await backupApi.create()
|
||||
toast.success(t('backup.toast.created'))
|
||||
await loadBackups()
|
||||
} catch {
|
||||
toast.error(t('backup.toast.createError'))
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
|
||||
setRestoringFile(filename)
|
||||
try {
|
||||
await backupApi.restore(filename)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadRestore = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
await backupApi.uploadRestore(file)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (filename) => {
|
||||
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
|
||||
try {
|
||||
await backupApi.delete(filename)
|
||||
toast.success(t('backup.toast.deleted'))
|
||||
setBackups(prev => prev.filter(b => b.filename !== filename))
|
||||
} catch {
|
||||
toast.error(t('backup.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSettingsChange = (key, value) => {
|
||||
setAutoSettings(prev => ({ ...prev, [key]: value }))
|
||||
setAutoSettingsDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveAutoSettings = async () => {
|
||||
setAutoSettingsSaving(true)
|
||||
try {
|
||||
const data = await backupApi.setAutoSettings(autoSettings)
|
||||
setAutoSettings(data.settings)
|
||||
setAutoSettingsDirty(false)
|
||||
toast.success(t('backup.toast.settingsSaved'))
|
||||
} catch {
|
||||
toast.error(t('backup.toast.settingsError'))
|
||||
} finally {
|
||||
setAutoSettingsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString(locale, {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
const isAuto = (filename) => filename.startsWith('auto-backup-')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Manual Backups */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title={t('backup.refresh')}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Upload & Restore */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
{isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
{isCreating ? t('backup.creating') : t('backup.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && backups.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{backups.map(backup => (
|
||||
<div key={backup.filename} className="flex items-center gap-4 py-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
{isAuto(backup.filename)
|
||||
? <RefreshCw className="w-4 h-4 text-blue-500" />
|
||||
: <HardDrive className="w-4 h-4 text-gray-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
|
||||
{isAuto(backup.filename) && (
|
||||
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
|
||||
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
>
|
||||
{restoringFile === backup.filename
|
||||
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <RotateCcw className="w-3.5 h-3.5" />
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Backup Settings */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{autoSettings.enabled && (
|
||||
<>
|
||||
{/* Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.interval === opt.value
|
||||
? 'bg-slate-700 text-white border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.keep_days === opt.value
|
||||
? 'bg-slate-700 text-white border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoSettingsSaving
|
||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Check className="w-4 h-4" />
|
||||
}
|
||||
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
||||
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
|
||||
'#6b7280', '#1f2937',
|
||||
]
|
||||
|
||||
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
|
||||
|
||||
export default function CategoryManager() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const colorInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => { loadCategories() }, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await categoriesApi.list()
|
||||
setCategories(data.categories || [])
|
||||
} catch (err) {
|
||||
toast.error(t('categories.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEdit = (cat) => {
|
||||
setEditingId(cat.id)
|
||||
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingId) {
|
||||
const result = await categoriesApi.update(editingId, form)
|
||||
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
|
||||
setEditingId(null)
|
||||
toast.success(t('categories.toast.updated'))
|
||||
} else {
|
||||
const result = await categoriesApi.create(form)
|
||||
setCategories(prev => [...prev, result.category])
|
||||
setShowForm(false)
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('categories.confirm.delete'))) return
|
||||
try {
|
||||
await categoriesApi.delete(id)
|
||||
setCategories(prev => prev.filter(c => c.id !== id))
|
||||
toast.success(t('categories.toast.deleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const isPresetColor = PRESET_COLORS.includes(form.color)
|
||||
const PreviewIcon = getCategoryIcon(form.icon)
|
||||
|
||||
const categoryForm = (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('categories.namePlaceholder')}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
|
||||
{ICON_NAMES.map(name => {
|
||||
const Icon = CATEGORY_ICON_MAP[name]
|
||||
const isSelected = form.icon === name
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={ICON_LABELS[name] || name}
|
||||
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-1 ring-slate-700'
|
||||
: 'hover:bg-gray-200'
|
||||
}`}
|
||||
style={{ background: isSelected ? `${form.color}18` : undefined }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map(color => (
|
||||
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
|
||||
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
style={{ backgroundColor: color }} />
|
||||
))}
|
||||
|
||||
{/* Custom color button */}
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('categories.customColor')}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
|
||||
!isPresetColor
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
|
||||
: 'border-dashed border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
|
||||
>
|
||||
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
|
||||
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
|
||||
<PreviewIcon size={14} strokeWidth={1.8} />
|
||||
{form.name || t('categories.defaultName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
|
||||
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
|
||||
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('categories.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && <div className="mb-4">{categoryForm}</div>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="text-sm">{t('categories.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{editingId === cat.id ? (
|
||||
<div className="mb-2">{categoryForm}</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={cat.color} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
|
||||
{cat.color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
const fmtNum = (v, locale, cur) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
||||
const calcPD = (p, d) => (d > 0 ? p / d : null)
|
||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||
|
||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value ?? '')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
const display = type === 'number' && value != null
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
function AddItemRow({ onAdd, t }) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const nameRef = useRef(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
|
||||
return (
|
||||
<tr style={{ background: 'var(--bg-secondary)' }}>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={t('budget.newEntry')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
function PieChart({ segments, size = 200, totalLabel }) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
if (total === 0) return null
|
||||
|
||||
let cumDeg = 0
|
||||
const stops = segments.map(seg => {
|
||||
const start = cumDeg
|
||||
const deg = (seg.value / total) * 360
|
||||
cumDeg += deg
|
||||
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||
}).join(', ')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: size * 0.55, height: size * 0.55,
|
||||
borderRadius: '50%', background: 'var(--bg-card)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function BudgetPanel({ tripId }) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
||||
const cat = item.category || 'Sonstiges'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
return acc
|
||||
}, {}), [budgetItems])
|
||||
|
||||
const categoryNames = Object.keys(grouped)
|
||||
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||
|
||||
const pieSegments = useMemo(() =>
|
||||
categoryNames.map((cat, i) => ({
|
||||
name: cat,
|
||||
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
|
||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
|
||||
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
|
||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
||||
const handleDeleteCategory = async (cat) => {
|
||||
const items = grouped[cat] || []
|
||||
for (const item of items) await deleteBudgetItem(tripId, item.id)
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||
setNewCategoryName(''); setShowAddCategory(false)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
// ── Empty State ──────────────────────────────────────────────────────────
|
||||
if (!budgetItems || budgetItems.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 600, margin: '60px auto', textAlign: 'center' }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Calculator size={28} color="#6b7280" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped[cat]
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = PIE_COLORS[ci % PIE_COLORS.length]
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
return (
|
||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Wallet size={18} color="rgba(255,255,255,0.8)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pieSegments.map(seg => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => (
|
||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Globe, MapPin, Plane } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
|
||||
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
|
||||
|
||||
// Our country names from addresses → match against GeoJSON names
|
||||
function isCountryMatch(geoName, visitedCountries) {
|
||||
if (!geoName) return false
|
||||
const lower = geoName.toLowerCase()
|
||||
return visitedCountries.some(c => {
|
||||
const cl = c.toLowerCase()
|
||||
return lower === cl || lower.includes(cl) || cl.includes(lower)
|
||||
// Handle common mismatches
|
||||
|| (cl === 'usa' && lower.includes('united states'))
|
||||
|| (cl === 'uk' && lower === 'united kingdom')
|
||||
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|
||||
|| (cl === 'deutschland' && lower === 'germany')
|
||||
|| (cl === 'frankreich' && lower === 'france')
|
||||
|| (cl === 'italien' && lower === 'italy')
|
||||
|| (cl === 'spanien' && lower === 'spain')
|
||||
|| (cl === 'österreich' && lower === 'austria')
|
||||
|| (cl === 'schweiz' && lower === 'switzerland')
|
||||
|| (cl === 'niederlande' && lower === 'netherlands')
|
||||
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|
||||
|| (cl === 'griechenland' && lower === 'greece')
|
||||
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|
||||
|| (cl === 'ägypten' && lower === 'egypt')
|
||||
|| (cl === 'südkorea' && lower.includes('korea'))
|
||||
|| (cl === 'indien' && lower === 'india')
|
||||
|| (cl === 'brasilien' && lower === 'brazil')
|
||||
|| (cl === 'argentinien' && lower === 'argentina')
|
||||
|| (cl === 'russland' && lower === 'russia')
|
||||
|| (cl === 'australien' && lower === 'australia')
|
||||
|| (cl === 'kanada' && lower === 'canada')
|
||||
|| (cl === 'mexiko' && lower === 'mexico')
|
||||
|| (cl === 'neuseeland' && lower === 'new zealand')
|
||||
|| (cl === 'singapur' && lower === 'singapore')
|
||||
|| (cl === 'kroatien' && lower === 'croatia')
|
||||
|| (cl === 'ungarn' && lower === 'hungary')
|
||||
|| (cl === 'rumänien' && lower === 'romania')
|
||||
|| (cl === 'polen' && lower === 'poland')
|
||||
|| (cl === 'schweden' && lower === 'sweden')
|
||||
|| (cl === 'norwegen' && lower === 'norway')
|
||||
|| (cl === 'dänemark' && lower === 'denmark')
|
||||
|| (cl === 'finnland' && lower === 'finland')
|
||||
|| (cl === 'irland' && lower === 'ireland')
|
||||
|| (cl === 'portugal' && lower === 'portugal')
|
||||
|| (cl === 'belgien' && lower === 'belgium')
|
||||
})
|
||||
}
|
||||
|
||||
const TOTAL_COUNTRIES = 195
|
||||
|
||||
// Simple Mercator projection for SVG
|
||||
function project(lon, lat, width, height) {
|
||||
const clampedLat = Math.max(-75, Math.min(83, lat))
|
||||
const x = ((lon + 180) / 360) * width
|
||||
const latRad = (clampedLat * Math.PI) / 180
|
||||
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
||||
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
function geoToPath(coords, width, height) {
|
||||
return coords.map((ring) => {
|
||||
// Split ring at dateline crossings to avoid horizontal stripes
|
||||
const segments = [[]]
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const [lon, lat] = ring[i]
|
||||
if (i > 0) {
|
||||
const prevLon = ring[i - 1][0]
|
||||
if (Math.abs(lon - prevLon) > 180) {
|
||||
// Dateline crossing — start new segment
|
||||
segments.push([])
|
||||
}
|
||||
}
|
||||
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
|
||||
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
return segments
|
||||
.filter(s => s.length > 2)
|
||||
.map(s => 'M' + s.join('L') + 'Z')
|
||||
.join(' ')
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
let geoJsonCache = null
|
||||
async function loadGeoJson() {
|
||||
if (geoJsonCache) return geoJsonCache
|
||||
try {
|
||||
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
||||
const topo = await res.json()
|
||||
const { feature } = await import('topojson-client')
|
||||
const geo = feature(topo, topo.objects.countries)
|
||||
geo.features.forEach(f => {
|
||||
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
|
||||
})
|
||||
geoJsonCache = geo
|
||||
return geo
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export default function TravelStats() {
|
||||
const { t } = useTranslation()
|
||||
const dark = useSettingsStore(s => s.settings.dark_mode)
|
||||
const [stats, setStats] = useState(null)
|
||||
const [geoData, setGeoData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.travelStats().then(setStats).catch(() => {})
|
||||
loadGeoJson().then(setGeoData)
|
||||
}, [])
|
||||
|
||||
const countryCount = stats?.countries?.length || 0
|
||||
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
|
||||
|
||||
if (!stats || stats.totalPlaces === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ width: 340 }}>
|
||||
{/* Stats Card */}
|
||||
<div style={{
|
||||
borderRadius: 20, overflow: 'hidden', height: 300,
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
padding: 16,
|
||||
}}>
|
||||
{/* Progress bar */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
|
||||
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 99,
|
||||
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
|
||||
width: `${Math.max(1, parseFloat(worldPercent))}%`,
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
|
||||
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
|
||||
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
|
||||
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
|
||||
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
|
||||
</div>
|
||||
|
||||
{/* Country tags */}
|
||||
{stats.countries.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{stats.countries.map(c => (
|
||||
<span key={c} style={{
|
||||
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
|
||||
}}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({ icon: Icon, value, label }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
||||
borderRadius: 10, background: 'var(--bg-hover)',
|
||||
}}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (isImage(mimeType)) return FileImage
|
||||
return File
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDateWithLocale(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
// Image lightbox
|
||||
function ImageLightbox({ file, onClose }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Source badge — unified style for both place and reservation
|
||||
function SourceBadge({ icon: Icon, label }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10.5, color: '#4b5563',
|
||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 6, padding: '2px 7px',
|
||||
fontWeight: 500, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of acceptedFiles) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await onUpload(formData)
|
||||
}
|
||||
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
||||
} catch {
|
||||
toast.error(t('files.uploadError'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [onUpload, toast, t])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
noClick: false,
|
||||
})
|
||||
|
||||
const filteredFiles = files.filter(f => {
|
||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||
if (filterType === 'image') return isImage(f.mime_type)
|
||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
||||
return true
|
||||
})
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('files.confirm.delete'))) return
|
||||
try {
|
||||
await onDelete(id)
|
||||
toast.success(t('files.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('files.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
setLightboxFile(file)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Lightbox */}
|
||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||
|
||||
{/* Datei-Vorschau Modal */}
|
||||
{previewFile && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</a>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||
{uploading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
{t('files.uploading')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontWeight: filterType === tab.id ? 600 : 400,
|
||||
}}>{tab.label}</button>
|
||||
))}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredFiles.map(file => {
|
||||
const FileIcon = getFileIcon(file.mime_type)
|
||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const fileUrl = file.url || `/uploads/files/${file.filename}`
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
transition: 'border-color 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||
className="group"
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
|
||||
>
|
||||
{file.original_name}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlace && (
|
||||
<SourceBadge
|
||||
icon={MapPin}
|
||||
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
|
||||
/>
|
||||
)}
|
||||
{linkedReservation && (
|
||||
<SourceBadge
|
||||
icon={Ticket}
|
||||
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file.description && !linkedReservation && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
|
||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
div:hover > .file-actions { opacity: 1 !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const texts = {
|
||||
de: {
|
||||
title: 'Willkommen zur NOMAD Demo',
|
||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
|
||||
features: [
|
||||
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
|
||||
'API-Schluessel verwalten (Google Maps, Wetter)',
|
||||
'Benutzer & Rechte verwalten',
|
||||
'Automatische Backups & Wiederherstellung',
|
||||
],
|
||||
selfHost: 'NOMAD ist Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
en: {
|
||||
title: 'Welcome to the NOMAD Demo',
|
||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||
resetIn: 'Next reset in',
|
||||
minutes: 'minutes',
|
||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||
fullVersionTitle: 'Additionally available in the full version:',
|
||||
features: [
|
||||
'File uploads (photos, documents, trip covers)',
|
||||
'API key management (Google Maps, Weather)',
|
||||
'User & permission management',
|
||||
'Automatic backups & restore',
|
||||
],
|
||||
selfHost: 'NOMAD is open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
}
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database]
|
||||
|
||||
export default function DemoBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes())
|
||||
const { language } = useTranslation()
|
||||
const t = texts[language] || texts.en
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (dismissed) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
|
||||
maxWidth: 440, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Info size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
|
||||
{t.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
|
||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
|
||||
}}>
|
||||
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
|
||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Upload size={15} style={{ flexShrink: 0 }} />
|
||||
{t.uploadNote}
|
||||
</p>
|
||||
|
||||
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t.fullVersionTitle}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
||||
{t.features.map((text, i) => {
|
||||
const Icon = featureIcons[i]
|
||||
return (
|
||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
|
||||
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
paddingTop: 16, borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
||||
<Github size={14} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setDismissed(true)} style={{
|
||||
background: '#111827', color: 'white', border: 'none',
|
||||
borderRadius: 10, padding: '8px 20px', fontSize: 13,
|
||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState(null)
|
||||
const dark = settings.dark_mode
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const toggleDark = () => {
|
||||
updateSetting('dark_mode', !dark).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<button onClick={onBack}
|
||||
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
|
||||
style={{ color: 'var(--text-primary)' }}>
|
||||
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
|
||||
</Link>
|
||||
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
<div className="relative">
|
||||
<button onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}>
|
||||
{user.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
{user.role === 'admin' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Shield className="w-3 h-3" /> {t('nav.administrator')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<button onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
||||
NOMAD v{appVersion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
const borderWidth = isSelected ? 3 : 2.5
|
||||
const shadow = isSelected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
const icon = place.category_icon || '📍'
|
||||
|
||||
// White semi-transparent number badge (bottom-right), only when orderNumber is set
|
||||
const badgeHtml = orderNumber != null ? `
|
||||
<span style="
|
||||
position:absolute;bottom:-3px;right:-3px;
|
||||
min-width:18px;height:18px;border-radius:9px;
|
||||
padding:0 3px;
|
||||
background:rgba(255,255,255,0.92);
|
||||
border:1.5px solid rgba(0,0,0,0.18);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:9px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;
|
||||
">${orderNumber}</span>` : ''
|
||||
|
||||
if (place.image_url) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:visible;background:${bgColor};
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;position:relative;
|
||||
">
|
||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId }) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
const place = places.find(p => p.id === selectedPlaceId)
|
||||
if (place?.lat && place?.lng) {
|
||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }) {
|
||||
const map = useMap()
|
||||
const prevCenter = useRef(center)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
|
||||
map.setView(center, zoom)
|
||||
prevCenter.current = center
|
||||
}
|
||||
}, [center, zoom, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
function BoundsController({ places, fitKey }) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
if (fitKey === prevFitKey.current) return
|
||||
prevFitKey.current = fitKey
|
||||
if (places.length === 0) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
|
||||
} catch {}
|
||||
}, [fitKey, places, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
map.on('click', onClick)
|
||||
return () => map.off('click', onClick)
|
||||
}, [map, onClick])
|
||||
return null
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
|
||||
export function MapView({
|
||||
places = [],
|
||||
route = null,
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
center = [48.8566, 2.3522],
|
||||
zoom = 10,
|
||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
}) {
|
||||
const [photoUrls, setPhotoUrls] = useState({})
|
||||
|
||||
// Fetch Google photos for places that have google_place_id but no image_url
|
||||
useEffect(() => {
|
||||
places.forEach(place => {
|
||||
if (place.image_url || !place.google_place_id) return
|
||||
if (mapPhotoCache.has(place.google_place_id)) {
|
||||
const cached = mapPhotoCache.get(place.google_place_id)
|
||||
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
|
||||
return
|
||||
}
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(place.google_place_id, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
|
||||
}
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
|
||||
})
|
||||
}, [places])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
zoomControl={false}
|
||||
className="w-full h-full"
|
||||
style={{ background: '#e5e7eb' }}
|
||||
>
|
||||
<TileLayer
|
||||
url={tileUrl}
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={places} fitKey={fitKey} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
||||
const orderNumber = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// OSRM routing utility - free, no API key required
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
/**
|
||||
* Calculate a route between multiple waypoints using OSRM
|
||||
* @param {Array<{lat: number, lng: number}>} waypoints
|
||||
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
||||
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
||||
*/
|
||||
export async function calculateRoute(waypoints, profile = 'driving') {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
throw new Error('Mindestens 2 Wegpunkte erforderlich')
|
||||
}
|
||||
|
||||
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||
// OSRM public API only supports driving; we override duration for other modes
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Route konnte nicht berechnet werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||
throw new Error('Keine Route gefunden')
|
||||
}
|
||||
|
||||
const route = data.routes[0]
|
||||
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
|
||||
|
||||
const distance = route.distance // meters
|
||||
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
|
||||
let duration
|
||||
if (profile === 'walking') {
|
||||
duration = distance / (5000 / 3600)
|
||||
} else if (profile === 'cycling') {
|
||||
duration = distance / (15000 / 3600)
|
||||
} else {
|
||||
duration = route.duration // driving: use OSRM value
|
||||
}
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distance,
|
||||
duration,
|
||||
distanceText: formatDistance(distance),
|
||||
durationText: formatDuration(duration),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Google Maps directions URL for the given places
|
||||
*/
|
||||
export function generateGoogleMapsUrl(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return null
|
||||
if (valid.length === 1) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
|
||||
}
|
||||
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
|
||||
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple nearest-neighbor route optimization
|
||||
*/
|
||||
export function optimizeRoute(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set()
|
||||
const result = []
|
||||
let current = valid[0]
|
||||
visited.add(current.id)
|
||||
result.push(current)
|
||||
|
||||
while (result.length < valid.length) {
|
||||
let nearest = null
|
||||
let minDist = Infinity
|
||||
for (const place of valid) {
|
||||
if (visited.has(place.id)) continue
|
||||
const d = Math.sqrt(
|
||||
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
|
||||
)
|
||||
if (d < minDist) { minDist = d; nearest = place }
|
||||
}
|
||||
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) {
|
||||
return `${h} Std. ${m} Min.`
|
||||
}
|
||||
return `${m} Min.`
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { mapsApi } from '../../api/client'
|
||||
|
||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
|
||||
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function absUrl(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||
}
|
||||
|
||||
function safeImg(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||
}
|
||||
|
||||
// Generate SVG string from Lucide icon name (for category thumbnails)
|
||||
let _renderToStaticMarkup = null
|
||||
async function ensureRenderer() {
|
||||
if (!_renderToStaticMarkup) {
|
||||
const mod = await import('react-dom/server')
|
||||
_renderToStaticMarkup = mod.renderToStaticMarkup
|
||||
}
|
||||
}
|
||||
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = getCategoryIcon(iconName)
|
||||
return _renderToStaticMarkup(
|
||||
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
|
||||
)
|
||||
}
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||
}
|
||||
|
||||
// Pre-fetch Google Place photos for all assigned places
|
||||
async function fetchPlacePhotos(assignments) {
|
||||
const photoMap = {} // placeId → photoUrl
|
||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||
|
||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
||||
|
||||
await Promise.allSettled(
|
||||
toFetch.map(async (place) => {
|
||||
try {
|
||||
const data = await mapsApi.placePhoto(place.google_place_id)
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||
} catch {}
|
||||
})
|
||||
)
|
||||
return photoMap
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || 'de-DE'
|
||||
const tr = _t || (k => k)
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
const range = longDateRange(sorted, loc)
|
||||
const coverImg = safeImg(trip?.cover_image)
|
||||
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
|
||||
const totalAssigned = new Set(
|
||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||
).size
|
||||
const totalCost = Object.values(assignments || {})
|
||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
let pi = 0
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'note') {
|
||||
const note = item.data
|
||||
return `
|
||||
<div class="note-card">
|
||||
<div class="note-line"></div>
|
||||
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
|
||||
<rect x="4" y="3" width="16" height="18" rx="2"/>
|
||||
<line x1="8" y1="8" x2="16" y2="8"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="13" y2="16"/>
|
||||
</svg>
|
||||
<div class="note-body">
|
||||
<div class="note-text">${escHtml(note.text)}</div>
|
||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
pi++
|
||||
const place = item.data.place
|
||||
if (!place) return ''
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const color = cat?.color || '#6366f1'
|
||||
|
||||
// Image: direct > google photo > fallback icon
|
||||
const directImg = safeImg(place.image_url)
|
||||
const googleImg = photoMap[place.id] || null
|
||||
const img = directImg || googleImg
|
||||
|
||||
const confirmed = place.reservation_status === 'confirmed'
|
||||
const pending = place.reservation_status === 'pending'
|
||||
|
||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||
const thumbHtml = img
|
||||
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
||||
: `<div class="place-thumb-fallback" style="background:${color}">
|
||||
${iconSvg}
|
||||
</div>`
|
||||
|
||||
const chips = [
|
||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
||||
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
|
||||
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
|
||||
].filter(Boolean).join('')
|
||||
|
||||
return `
|
||||
<div class="place-card">
|
||||
<div class="place-bar" style="background:${color}"></div>
|
||||
${thumbHtml}
|
||||
<div class="place-info">
|
||||
<div class="place-name-row">
|
||||
<span class="place-num">${pi}</span>
|
||||
<span class="place-name">${escHtml(place.name)}</span>
|
||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||
<div class="day-header">
|
||||
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
||||
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
|
||||
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||
</div>
|
||||
<div class="day-body">${itemsHtml}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base href="${window.location.origin}/">
|
||||
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
/* ── Cover ─────────────────────────────────────── */
|
||||
.cover {
|
||||
width: 100%; min-height: 100vh;
|
||||
background: #0f172a;
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
padding: 52px; position: relative; overflow: hidden;
|
||||
}
|
||||
.cover-bg {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
opacity: 0.28;
|
||||
}
|
||||
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
||||
.cover-brand {
|
||||
position: absolute; top: 36px; right: 52px;
|
||||
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
|
||||
color: rgba(255,255,255,0.3); text-transform: uppercase;
|
||||
}
|
||||
.cover-body { position: relative; z-index: 1; }
|
||||
.cover-circle {
|
||||
width: 100px; height: 100px; border-radius: 50%;
|
||||
overflow: hidden; border: 2.5px solid rgba(255,255,255,0.25);
|
||||
margin-bottom: 26px; flex-shrink: 0;
|
||||
}
|
||||
.cover-circle img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-circle-ph {
|
||||
width: 100px; height: 100px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.07);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.cover-label { font-size: 9px; font-weight: 600; letter-spacing: 2.5px; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 8px; }
|
||||
.cover-title { font-size: 42px; font-weight: 700; color: #fff; line-height: 1.1; margin-bottom: 8px; }
|
||||
.cover-desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.6; margin-bottom: 18px; max-width: 420px; }
|
||||
.cover-dates { font-size: 12px; color: rgba(255,255,255,0.45); margin-bottom: 30px; }
|
||||
.cover-line { height: 1px; background: rgba(255,255,255,0.1); margin-bottom: 24px; }
|
||||
.cover-stats { display: flex; gap: 36px; }
|
||||
.cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; }
|
||||
.cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; }
|
||||
|
||||
/* ── Day ───────────────────────────────────────── */
|
||||
.page-break { page-break-before: always; }
|
||||
.day-header {
|
||||
background: #0f172a; padding: 11px 28px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.day-tag { font-size: 8px; font-weight: 700; color: #fff; letter-spacing: 0.8px; background: rgba(255,255,255,0.12); border-radius: 4px; padding: 3px 8px; flex-shrink: 0; }
|
||||
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
|
||||
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
|
||||
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
|
||||
.day-body { padding: 12px 28px 6px; }
|
||||
|
||||
/* ── Place card ────────────────────────────────── */
|
||||
.place-card {
|
||||
display: flex; align-items: stretch;
|
||||
border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
margin-bottom: 8px; overflow: hidden;
|
||||
background: #fff; page-break-inside: avoid;
|
||||
}
|
||||
.place-bar { width: 4px; flex-shrink: 0; }
|
||||
.place-thumb {
|
||||
width: 52px; height: 52px; object-fit: cover;
|
||||
margin: 8px; border-radius: 6px; flex-shrink: 0;
|
||||
}
|
||||
.place-thumb-fallback {
|
||||
width: 52px; height: 52px; margin: 8px; border-radius: 8px;
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.place-thumb-fallback svg { width: 24px; height: 24px; }
|
||||
.place-info { flex: 1; padding: 9px 10px 8px 0; min-width: 0; }
|
||||
|
||||
.place-name-row { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; }
|
||||
.place-num {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #1e293b; color: #fff; font-size: 8px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.place-name { font-size: 11.5px; font-weight: 600; color: #1e293b; flex: 1; }
|
||||
.cat-badge { font-size: 7.5px; font-weight: 600; color: #fff; border-radius: 99px; padding: 2px 7px; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.info-row { display: flex; align-items: flex-start; gap: 4px; margin-bottom: 2px; padding-left: 21px; }
|
||||
.info-row svg { flex-shrink: 0; margin-top: 1px; }
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
.chip svg { flex-shrink: 0; }
|
||||
.chip-green { background: #ecfdf5; color: #059669; }
|
||||
.chip-amber { background: #fffbeb; color: #d97706; }
|
||||
|
||||
/* ── Note card ─────────────────────────────────── */
|
||||
.note-card {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
padding: 8px 10px; margin-bottom: 7px; page-break-inside: avoid;
|
||||
}
|
||||
.note-line { width: 3px; border-radius: 99px; background: #94a3b8; align-self: stretch; flex-shrink: 0; }
|
||||
.note-icon { flex-shrink: 0; }
|
||||
.note-body { flex: 1; min-width: 0; }
|
||||
.note-text { font-size: 9.5px; color: #334155; line-height: 1.55; }
|
||||
.note-time { font-size: 8px; color: #94a3b8; margin-top: 2px; }
|
||||
|
||||
.empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; }
|
||||
|
||||
/* ── Print ─────────────────────────────────────── */
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.cover { min-height: 100vh; page-break-after: always; }
|
||||
@page { margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="cover">
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-brand">NOMAD</div>
|
||||
<div class="cover-body">
|
||||
${coverImg
|
||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||
: `<div class="cover-circle-ph"></div>`}
|
||||
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
|
||||
<div class="cover-title">${escHtml(trip?.title || 'Meine Reise')}</div>
|
||||
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
||||
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
||||
<div class="cover-line"></div>
|
||||
<div class="cover-stats">
|
||||
<div>
|
||||
<div class="cover-stat-num">${sorted.length}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${places?.length || 0}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${totalAssigned}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||
</div>
|
||||
${totalCost > 0 ? `<div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Days -->
|
||||
${daysHtml}
|
||||
|
||||
</body></html>`
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'pdf-preview-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
|
||||
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove()
|
||||
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import React, { useState, useMemo, useRef } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
|
||||
} from 'lucide-react'
|
||||
|
||||
const VORSCHLAEGE = [
|
||||
{ name: 'Reisepass', kategorie: 'Dokumente' },
|
||||
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
|
||||
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
|
||||
{ name: 'Flugtickets', kategorie: 'Dokumente' },
|
||||
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
|
||||
{ name: 'Impfpass', kategorie: 'Dokumente' },
|
||||
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
|
||||
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
|
||||
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
|
||||
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
|
||||
{ name: 'Jacke', kategorie: 'Kleidung' },
|
||||
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
|
||||
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
|
||||
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
|
||||
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
|
||||
{ name: 'Shampoo', kategorie: 'Körperpflege' },
|
||||
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
|
||||
{ name: 'Deo', kategorie: 'Körperpflege' },
|
||||
{ name: 'Rasierer', kategorie: 'Körperpflege' },
|
||||
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
|
||||
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
|
||||
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
|
||||
{ name: 'Kamera', kategorie: 'Elektronik' },
|
||||
{ name: 'Powerbank', kategorie: 'Elektronik' },
|
||||
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
|
||||
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
|
||||
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
|
||||
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
|
||||
{ name: 'Bargeld', kategorie: 'Finanzen' },
|
||||
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
|
||||
]
|
||||
|
||||
// Cycling color palette — works in light & dark mode
|
||||
const KAT_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#22c55e', // green
|
||||
'#f97316', // orange
|
||||
'#06b6d4', // cyan
|
||||
'#ef4444', // red
|
||||
'#eab308', // yellow
|
||||
'#8b5cf6', // violet
|
||||
'#14b8a6', // teal
|
||||
]
|
||||
// Stable color assignment: category name → index via simple hash
|
||||
function katColor(kat, allCategories) {
|
||||
const idx = allCategories ? allCategories.indexOf(kat) : -1
|
||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
||||
// Fallback: hash-based
|
||||
let h = 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
|
||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||
catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try { await deletePackingItem(tripId, item.id) }
|
||||
catch { toast.error(t('packing.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const handleCatChange = async (cat) => {
|
||||
setShowCatPicker(false)
|
||||
if (cat === item.category) return
|
||||
try { await updatePackingItem(tripId, item.id, { category: cat }) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
||||
background: hovered ? 'var(--bg-secondary)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<button onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}>
|
||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSaveName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
|
||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => !item.checked && setEditing(true)}
|
||||
style={{
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowCatPicker(p => !p)}
|
||||
title={t('packing.changeCategory')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
|
||||
</button>
|
||||
{showCatPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||
padding: 4, minWidth: 140,
|
||||
}}>
|
||||
{categories.map(cat => (
|
||||
<button key={cat} onClick={() => handleCatChange(cat)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
|
||||
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const { togglePackingItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const abgehakt = items.filter(i => i.checked).length
|
||||
const alleAbgehakt = abgehakt === items.length
|
||||
const dot = katColor(kategorie, allCategories)
|
||||
|
||||
const handleSaveKatName = async () => {
|
||||
const neu = editKatName.trim()
|
||||
if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return }
|
||||
try { await onRename(kategorie, neu); setEditingName(false) }
|
||||
catch { toast.error(t('packing.toast.renameError')) }
|
||||
}
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
for (const item of items) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
}
|
||||
const handleUncheckAll = async () => {
|
||||
for (const item of items) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
}
|
||||
const handleDeleteAll = async () => {
|
||||
await onDeleteAll(items)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName ? (
|
||||
<input
|
||||
autoFocus value={editKatName}
|
||||
onChange={e => setEditKatName(e.target.value)}
|
||||
onBlur={handleSaveKatName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
|
||||
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
|
||||
{kategorie}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
|
||||
}}>
|
||||
{abgehakt}/{items.length}
|
||||
</span>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
|
||||
color: danger ? '#ef4444' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Haupt-Panel ────────────────────────────────────────────────────────────
|
||||
export default function PackingListPanel({ tripId, items }) {
|
||||
const [neuerName, setNeuerName] = useState('')
|
||||
const [neueKategorie, setNeueKategorie] = useState('')
|
||||
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
const [showKatDropdown, setShowKatDropdown] = useState(false)
|
||||
const katInputRef = useRef(null)
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
|
||||
return Array.from(cats).sort()
|
||||
}, [items, t])
|
||||
|
||||
const gruppiert = useMemo(() => {
|
||||
const filtered = items.filter(i => {
|
||||
if (filter === 'offen') return !i.checked
|
||||
if (filter === 'erledigt') return i.checked
|
||||
return true
|
||||
})
|
||||
const groups = {}
|
||||
for (const item of filtered) {
|
||||
const kat = item.category || t('packing.defaultCategory')
|
||||
if (!groups[kat]) groups[kat] = []
|
||||
groups[kat].push(item)
|
||||
}
|
||||
return groups
|
||||
}, [items, filter, t])
|
||||
|
||||
const abgehakt = items.filter(i => i.checked).length
|
||||
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!neuerName.trim()) return
|
||||
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
|
||||
try {
|
||||
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
|
||||
setNeuerName('')
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
|
||||
|
||||
const handleVorschlag = async (v) => {
|
||||
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
|
||||
catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
|
||||
for (const item of toUpdate) {
|
||||
await updatePackingItem(tripId, item.id, { category: newName })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (catItems) => {
|
||||
for (const item of catItems) {
|
||||
try { await deletePackingItem(tripId, item.id) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearChecked = async () => {
|
||||
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
||||
for (const item of items.filter(i => i.checked)) {
|
||||
try { await deletePackingItem(tripId, item.id) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
|
||||
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Sparkles size={12} /> {t('packing.suggestions')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
|
||||
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
|
||||
width: `${fortschritt}%`,
|
||||
}} />
|
||||
</div>
|
||||
{fortschritt === 100 && (
|
||||
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
|
||||
placeholder={t('packing.addPlaceholder')}
|
||||
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={katInputRef}
|
||||
type="text" value={neueKategorie}
|
||||
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
|
||||
onFocus={() => setShowKatDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
|
||||
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
|
||||
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
{showKatDropdown && allCategories.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
|
||||
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
|
||||
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Vorschläge ── */}
|
||||
{zeigeVorschlaege && (
|
||||
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
|
||||
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
||||
<X size={14} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
|
||||
{verfuegbareVorschlaege.map((v, i) => (
|
||||
<button key={i} onClick={() => handleVorschlag(v)} style={{
|
||||
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
+ {v.name}
|
||||
</button>
|
||||
))}
|
||||
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
{items.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
|
||||
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
|
||||
<button key={id} onClick={() => setFilter(id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
|
||||
background: filter === id ? 'var(--text-primary)' : 'transparent',
|
||||
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Liste ── */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
|
||||
</div>
|
||||
) : Object.keys(gruppiert).length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
|
||||
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(gruppiert).map(([kat, katItems]) => (
|
||||
<KategorieGruppe
|
||||
key={kat}
|
||||
kategorie={kat}
|
||||
items={katItems}
|
||||
tripId={tripId}
|
||||
allCategories={allCategories}
|
||||
onRename={handleRenameCategory}
|
||||
onDeleteAll={handleDeleteCategory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { PhotoLightbox } from './PhotoLightbox'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
import { Upload, Camera } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [filterDayId, setFilterDayId] = useState('')
|
||||
|
||||
const filteredPhotos = useMemo(() => {
|
||||
return photos.filter(photo => {
|
||||
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false
|
||||
return true
|
||||
})
|
||||
}, [photos, filterDayId])
|
||||
|
||||
const handlePhotoClick = (photo) => {
|
||||
const idx = filteredPhotos.findIndex(p => p.id === photo.id)
|
||||
setLightboxIndex(idx)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId) => {
|
||||
await onDelete(photoId)
|
||||
if (lightboxIndex !== null) {
|
||||
const newPhotos = filteredPhotos.filter(p => p.id !== photoId)
|
||||
if (newPhotos.length === 0) {
|
||||
setLightboxIndex(null)
|
||||
} else if (lightboxIndex >= newPhotos.length) {
|
||||
setLightboxIndex(newPhotos.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div style={{ marginRight: 'auto' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
||||
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterDayId}
|
||||
onChange={e => setFilterDayId(e.target.value)}
|
||||
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
>
|
||||
<option value="">Alle Tage</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>
|
||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filterDayId && (
|
||||
<button
|
||||
onClick={() => setFilterDayId('')}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredPhotos.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
|
||||
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>Noch keine Fotos</p>
|
||||
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p>
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
|
||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
|
||||
{filteredPhotos.map(photo => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
days={days}
|
||||
places={places}
|
||||
onClick={() => handlePhotoClick(photo)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Upload tile */}
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span className="text-xs">Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<PhotoLightbox
|
||||
photos={filteredPhotos}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
days={days}
|
||||
places={places}
|
||||
tripId={tripId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal
|
||||
isOpen={showUpload}
|
||||
onClose={() => setShowUpload(false)}
|
||||
title="Fotos hochladen"
|
||||
size="lg"
|
||||
>
|
||||
<PhotoUpload
|
||||
tripId={tripId}
|
||||
days={days}
|
||||
places={places}
|
||||
onUpload={async (formData) => {
|
||||
await onUpload(formData)
|
||||
setShowUpload(false)
|
||||
}}
|
||||
onClose={() => setShowUpload(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoThumbnail({ photo, days, places, onClick }) {
|
||||
const day = days?.find(d => d.id === photo.day_id)
|
||||
const place = places?.find(p => p.id === photo.place_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="aspect-square rounded-xl overflow-hidden cursor-pointer relative group bg-gray-100"
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || photo.original_name}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fallback */}
|
||||
<div className="hidden absolute inset-0 items-center justify-center text-gray-400 text-2xl">
|
||||
🖼️
|
||||
</div>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex flex-col justify-end p-2 opacity-0 group-hover:opacity-100">
|
||||
{photo.caption && (
|
||||
<p className="text-white text-xs font-medium truncate">{photo.caption}</p>
|
||||
)}
|
||||
{(day || place) && (
|
||||
<p className="text-white/70 text-xs truncate">
|
||||
{day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
|
||||
|
||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
|
||||
const [index, setIndex] = useState(initialIndex || 0)
|
||||
const [editCaption, setEditCaption] = useState(false)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const photo = photos[index]
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(initialIndex || 0)
|
||||
}, [initialIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (photo) setCaption(photo.caption || '')
|
||||
}, [photo])
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIndex(i => Math.max(0, i - 1))
|
||||
setEditCaption(false)
|
||||
}, [])
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex(i => Math.min(photos.length - 1, i + 1))
|
||||
setEditCaption(false)
|
||||
}, [photos.length])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose, prev, next])
|
||||
|
||||
const handleSaveCaption = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onUpdate(photo.id, { caption })
|
||||
setEditCaption(false)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Foto löschen?')) return
|
||||
await onDelete(photo.id)
|
||||
if (photos.length <= 1) {
|
||||
onClose()
|
||||
} else {
|
||||
setIndex(i => Math.min(i, photos.length - 2))
|
||||
}
|
||||
}
|
||||
|
||||
if (!photo) return null
|
||||
|
||||
const day = days?.find(d => d.id === photo.day_id)
|
||||
const place = places?.find(p => p.id === photo.place_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Main area */}
|
||||
<div
|
||||
className="relative flex flex-col w-full h-full max-w-5xl mx-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<div className="text-white/60 text-sm">
|
||||
{index + 1} / {photos.length}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
<div className="flex-1 flex items-center justify-center relative min-h-0 px-16">
|
||||
{/* Prev button */}
|
||||
{index > 0 && (
|
||||
<button
|
||||
onClick={prev}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || photo.original_name}
|
||||
className="max-h-full max-w-full object-contain rounded-lg select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Next button */}
|
||||
{index < photos.length - 1 && (
|
||||
<button
|
||||
onClick={next}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom info */}
|
||||
<div className="flex-shrink-0 p-4">
|
||||
{/* Caption */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{editCaption ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
|
||||
placeholder="Beschriftung hinzufügen..."
|
||||
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveCaption}
|
||||
disabled={isSaving}
|
||||
className="p-1.5 bg-slate-900 text-white rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditCaption(false); setCaption(photo.caption || '') }}
|
||||
className="p-1.5 text-white/60 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
|
||||
onClick={() => setEditCaption(true)}
|
||||
>
|
||||
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditCaption(true)}
|
||||
className="p-1.5 text-white/40 hover:text-white/70"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-white/40 text-xs">
|
||||
<span>{photo.original_name}</span>
|
||||
{photo.created_at && (
|
||||
<span>{formatDate(photo.created_at)}</span>
|
||||
)}
|
||||
{day && <span>📅 Tag {day.day_number}</span>}
|
||||
{place && <span>📍 {place.name}</span>}
|
||||
{photo.file_size && <span>{formatSize(photo.file_size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex-shrink-0 px-4 pb-4">
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setIndex(i); setEditCaption(false) }}
|
||||
className={`flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden transition-all ${
|
||||
i === index
|
||||
? 'ring-2 ring-white scale-105'
|
||||
: 'opacity-50 hover:opacity-75'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={p.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, X, Image } from 'lucide-react'
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [dayId, setDayId] = useState('')
|
||||
const [placeId, setPlaceId] = useState('')
|
||||
const [caption, setCaption] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
const withPreview = acceptedFiles.map(file =>
|
||||
Object.assign(file, { preview: URL.createObjectURL(file) })
|
||||
)
|
||||
setFiles(prev => [...prev, ...withPreview])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.heic'] },
|
||||
maxFiles: 30,
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
})
|
||||
|
||||
const removeFile = (index) => {
|
||||
setFiles(prev => {
|
||||
URL.revokeObjectURL(prev[index].preview)
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
files.forEach(file => formData.append('photos', file))
|
||||
if (dayId) formData.append('day_id', dayId)
|
||||
if (placeId) formData.append('place_id', placeId)
|
||||
if (caption) formData.append('caption', caption)
|
||||
|
||||
await onUpload(formData)
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
setFiles([])
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDragActive
|
||||
? 'border-slate-900 bg-slate-50'
|
||||
: 'border-gray-300 hover:border-slate-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
{isDragActive ? (
|
||||
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
|
||||
<p className="text-gray-400 text-sm mt-1">oder klicken zum Auswählen</p>
|
||||
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview grid */}
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="relative aspect-square group">
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeFile(idx)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity truncate">
|
||||
{formatSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label>
|
||||
<select
|
||||
value={dayId}
|
||||
onChange={e => setDayId(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
>
|
||||
<option value="">Kein Tag</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>Tag {day.day_number}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label>
|
||||
<select
|
||||
value={placeId}
|
||||
onChange={e => setPlaceId(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
>
|
||||
<option value="">Kein Ort</option>
|
||||
{(places || []).map(place => (
|
||||
<option key={place.id} value={place.id}>{place.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
placeholder="Optionale Beschriftung..."
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-slate-900 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || uploading}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploading ? 'Hochladen...' : `${files.length} Foto${files.length !== 1 ? 's' : ''} hochladen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { Search, Plus, MapPin, Loader } from 'lucide-react'
|
||||
|
||||
const STATUSES = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
]
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
place,
|
||||
tripId,
|
||||
categories: initialCategories = [],
|
||||
tags: initialTags = [],
|
||||
onCategoryCreated,
|
||||
onTagCreated,
|
||||
}) {
|
||||
const isEditing = !!place
|
||||
const { user } = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const [categories, setCategories] = useState(initialCategories)
|
||||
const [tags, setTags] = useState(initialTags)
|
||||
|
||||
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
|
||||
useEffect(() => { setTags(initialTags) }, [initialTags])
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
lat: '',
|
||||
lng: '',
|
||||
category_id: '',
|
||||
place_time: '',
|
||||
reservation_status: 'none',
|
||||
reservation_notes: '',
|
||||
reservation_datetime: '',
|
||||
google_place_id: '',
|
||||
website: '',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Maps search state
|
||||
const [mapQuery, setMapQuery] = useState('')
|
||||
const [mapResults, setMapResults] = useState([])
|
||||
const [mapSearching, setMapSearching] = useState(false)
|
||||
|
||||
// New category/tag
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [newTagName, setNewTagName] = useState('')
|
||||
const [newTagColor, setNewTagColor] = useState('#374151')
|
||||
const [showNewTag, setShowNewTag] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (place && isOpen) {
|
||||
setFormData({
|
||||
name: place.name || '',
|
||||
description: place.description || '',
|
||||
address: place.address || '',
|
||||
lat: place.lat ?? '',
|
||||
lng: place.lng ?? '',
|
||||
category_id: place.category_id || '',
|
||||
place_time: place.place_time || '',
|
||||
reservation_status: place.reservation_status || 'none',
|
||||
reservation_notes: place.reservation_notes || '',
|
||||
reservation_datetime: place.reservation_datetime || '',
|
||||
google_place_id: place.google_place_id || '',
|
||||
website: place.website || '',
|
||||
tags: (place.tags || []).map(t => t.id),
|
||||
})
|
||||
} else if (!place && isOpen) {
|
||||
setFormData(emptyForm)
|
||||
}
|
||||
setError('')
|
||||
setMapResults([])
|
||||
setMapQuery('')
|
||||
}, [place, isOpen])
|
||||
|
||||
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.includes(tagId)
|
||||
? prev.tags.filter(id => id !== tagId)
|
||||
: [...prev.tags, tagId]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!formData.name.trim()) {
|
||||
setError('Place name is required')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave({
|
||||
...formData,
|
||||
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
|
||||
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
|
||||
category_id: formData.category_id || null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save place')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMapSearch = async () => {
|
||||
if (!mapQuery.trim()) return
|
||||
setMapSearching(true)
|
||||
try {
|
||||
const data = await mapsApi.search(mapQuery)
|
||||
setMapResults(data.places || [])
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Maps search failed')
|
||||
} finally {
|
||||
setMapSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectMapPlace = (p) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: p.name || prev.name,
|
||||
address: p.address || prev.address,
|
||||
lat: p.lat ?? prev.lat,
|
||||
lng: p.lng ?? prev.lng,
|
||||
google_place_id: p.google_place_id || prev.google_place_id,
|
||||
website: p.website || prev.website,
|
||||
}))
|
||||
setMapResults([])
|
||||
setMapQuery('')
|
||||
}
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
try {
|
||||
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
|
||||
setCategories(prev => [...prev, data.category])
|
||||
if (onCategoryCreated) onCategoryCreated(data.category)
|
||||
setFormData(prev => ({ ...prev, category_id: data.category.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
toast.success('Category created')
|
||||
} catch (err) {
|
||||
toast.error('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return
|
||||
try {
|
||||
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
|
||||
setTags(prev => [...prev, data.tag])
|
||||
if (onTagCreated) onTagCreated(data.tag)
|
||||
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
|
||||
setNewTagName('')
|
||||
setShowNewTag(false)
|
||||
toast.success('Tag created')
|
||||
} catch (err) {
|
||||
toast.error('Failed to create tag')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Edit Place' : 'Add Place'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : isEditing ? 'Save Changes' : 'Add Place'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Maps search — always visible when API key is set */}
|
||||
{user?.maps_api_key && (
|
||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={mapQuery}
|
||||
onChange={e => setMapQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
|
||||
placeholder="Google Maps suchen..."
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMapSearch}
|
||||
disabled={mapSearching}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mapResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
|
||||
{mapResults.map((p, i) => (
|
||||
<button
|
||||
key={p.google_place_id || i}
|
||||
onClick={() => selectMapPlace(p)}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{p.name}</p>
|
||||
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{p.address}
|
||||
</p>
|
||||
{p.rating && (
|
||||
<p className="text-xs text-amber-600 mt-0.5">★ {p.rating}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => update('name', e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Eiffel Tower"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
placeholder="Notes about this place..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={e => update('address', e.target.value)}
|
||||
placeholder="Street address"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lat / Lng */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.lat}
|
||||
onChange={e => update('lat', e.target.value)}
|
||||
placeholder="e.g. 48.8584"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.lng}
|
||||
onChange={e => update('lng', e.target.value)}
|
||||
placeholder="e.g. 2.2945"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={formData.category_id}
|
||||
onChange={e => update('category_id', e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewCategory(!showNewCategory)}
|
||||
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
|
||||
title="Create new category"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewCategory && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
placeholder="Category name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={newCategoryColor}
|
||||
onChange={e => setNewCategoryColor(e.target.value)}
|
||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
||||
title="Category color"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
|
||||
formData.tags.includes(tag.id)
|
||||
? 'text-white shadow-sm ring-2 ring-offset-1'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: tag.color || '#374151',
|
||||
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewTag(!showNewTag)}
|
||||
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="inline w-3 h-3 mr-0.5" />
|
||||
New tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewTag && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={e => setNewTagName(e.target.value)}
|
||||
placeholder="Tag name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={newTagColor}
|
||||
onChange={e => setNewTagColor(e.target.value)}
|
||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTag}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time & Reservation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.place_time}
|
||||
onChange={e => update('place_time', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
|
||||
<select
|
||||
value={formData.reservation_status}
|
||||
onChange={e => update('reservation_status', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
>
|
||||
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reservation details */}
|
||||
{formData.reservation_status !== 'none' && (
|
||||
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.reservation_datetime}
|
||||
onChange={e => update('reservation_datetime', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
|
||||
<textarea
|
||||
value={formData.reservation_notes}
|
||||
onChange={e => update('reservation_notes', e.target.value)}
|
||||
placeholder="Confirmation number, special requests..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={e => update('website', e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, MapPin } from 'lucide-react'
|
||||
|
||||
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
|
||||
const { place } = assignment
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: `assignment-${assignment.id}`,
|
||||
data: {
|
||||
type: 'assignment',
|
||||
dayId: dayId,
|
||||
assignment,
|
||||
},
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
const reservationIcon = () => {
|
||||
if (place.reservation_status === 'confirmed') {
|
||||
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" title="Confirmed" />
|
||||
}
|
||||
if (place.reservation_status === 'pending') {
|
||||
return <Clock3 className="w-3.5 h-3.5 text-amber-500" title="Pending" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
group bg-white border rounded-lg p-2.5 transition-all
|
||||
${isDragging
|
||||
? 'opacity-40 border-slate-300 shadow-lg'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Name row */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{place.category && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
|
||||
{reservationIcon()}
|
||||
</div>
|
||||
|
||||
{/* Time & price row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{place.place_time && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</span>
|
||||
)}
|
||||
{place.price != null && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{place.tags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(place)}
|
||||
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
|
||||
title="Edit place"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(assignment.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="Remove from day"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import AssignedPlaceItem from './AssignedPlaceItem'
|
||||
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
|
||||
|
||||
export default function DayColumn({
|
||||
day,
|
||||
assignments,
|
||||
tripId,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
onQuickAdd,
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
const [notes, setNotes] = useState(day.notes || '')
|
||||
const [notesEditing, setNotesEditing] = useState(false)
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `day-${day.id}`,
|
||||
data: {
|
||||
type: 'day',
|
||||
dayId: day.id,
|
||||
},
|
||||
})
|
||||
|
||||
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
|
||||
|
||||
const totalCost = (assignments || []).reduce((sum, a) => {
|
||||
return sum + (a.place?.price ? Number(a.place.price) : 0)
|
||||
}, 0)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return null
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
|
||||
: 'border-transparent bg-white shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
|
||||
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
|
||||
{assignments?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{day.date && (
|
||||
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{totalCost > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{totalCost.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Notes"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes area */}
|
||||
{showNotes && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => setNotesEditing(false)}
|
||||
onFocus={() => setNotesEditing(true)}
|
||||
placeholder="Add notes for this day..."
|
||||
rows={2}
|
||||
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
|
||||
/>
|
||||
{notesEditing && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
// Parent will handle save via onUpdateNotes if passed
|
||||
}}
|
||||
className="text-xs text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignments list */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`
|
||||
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
|
||||
${isOver ? 'bg-slate-50' : 'bg-transparent'}
|
||||
`}
|
||||
>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{assignments.map(assignment => (
|
||||
<AssignedPlaceItem
|
||||
key={assignment.id}
|
||||
assignment={assignment}
|
||||
dayId={day.id}
|
||||
onRemove={(id) => onRemoveAssignment(day.id, id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div className={`
|
||||
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
|
||||
text-xs text-center transition-colors
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-100 text-slate-500'
|
||||
: 'border-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
<Package className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="font-medium">Drop places here</p>
|
||||
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add button */}
|
||||
<button
|
||||
onClick={() => onQuickAdd(day)}
|
||||
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add place
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCollapsed && (
|
||||
<div
|
||||
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
>
|
||||
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} — click to expand
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
return locale?.startsWith('de') ? `${str} Uhr` : str
|
||||
} catch { return timeStr }
|
||||
}
|
||||
|
||||
function dayTotalCost(dayId, assignments, currency) {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
|
||||
}
|
||||
|
||||
const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
{ id: 'Info', Icon: Info },
|
||||
{ id: 'Clock', Icon: Clock },
|
||||
{ id: 'MapPin', Icon: MapPin },
|
||||
{ id: 'Navigation', Icon: Navigation },
|
||||
{ id: 'Train', Icon: Train },
|
||||
{ id: 'Plane', Icon: Plane },
|
||||
{ id: 'Bus', Icon: Bus },
|
||||
{ id: 'Car', Icon: Car },
|
||||
{ id: 'Ship', Icon: Ship },
|
||||
{ id: 'Coffee', Icon: Coffee },
|
||||
{ id: 'Ticket', Icon: Ticket },
|
||||
{ id: 'Star', Icon: Star },
|
||||
{ id: 'Heart', Icon: Heart },
|
||||
{ id: 'Camera', Icon: Camera },
|
||||
{ id: 'Flag', Icon: Flag },
|
||||
{ id: 'Lightbulb', Icon: Lightbulb },
|
||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||
{ id: 'Bookmark', Icon: Bookmark },
|
||||
]
|
||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||
function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||
|
||||
const TYPE_ICONS = {
|
||||
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId,
|
||||
onSelectDay, onPlaceClick,
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const TRANSPORT_MODES = [
|
||||
{ value: 'driving', label: t('dayplan.transport.car') },
|
||||
{ value: 'walking', label: t('dayplan.transport.walk') },
|
||||
{ value: 'cycling', label: t('dayplan.transport.bike') },
|
||||
]
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [transportMode, setTransportMode] = useState('driving')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
|
||||
const inputRef = useRef(null)
|
||||
const noteInputRef = useRef(null)
|
||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
|
||||
if (dragDataRef.current) {
|
||||
return {
|
||||
placeId: '',
|
||||
assignmentId: dragDataRef.current.assignmentId || '',
|
||||
noteId: dragDataRef.current.noteId || '',
|
||||
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
|
||||
}
|
||||
}
|
||||
// Externer Drag (aus PlacesSidebar)
|
||||
const ext = window.__dragData || {}
|
||||
const placeId = dt?.getData('placeId') || ext.placeId || ''
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
|
||||
}, [days.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingDayId && inputRef.current) inputRef.current.focus()
|
||||
}, [editingDayId])
|
||||
|
||||
// Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen
|
||||
useEffect(() => {
|
||||
const cleanup = () => {
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
setDragOverDayId(null)
|
||||
dragDataRef.current = null
|
||||
window.__dragData = null
|
||||
}
|
||||
document.addEventListener('dragend', cleanup)
|
||||
return () => document.removeEventListener('dragend', cleanup)
|
||||
}, [])
|
||||
|
||||
const toggleDay = (dayId, e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
const merged = getMergedItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
|
||||
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note, e) => {
|
||||
e?.stopPropagation()
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const deleteNote = async (dayId, noteId, e) => {
|
||||
e?.stopPropagation()
|
||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
|
||||
const m = getMergedItems(dayId)
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||
|
||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
|
||||
// Orte: neuer order_index über onReorder
|
||||
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
|
||||
|
||||
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
|
||||
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
|
||||
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
|
||||
const groups = {}
|
||||
let pc = 0
|
||||
newOrder.forEach(item => {
|
||||
if (item.type === 'place') { pc++ }
|
||||
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
|
||||
})
|
||||
const noteChanges = []
|
||||
Object.entries(groups).forEach(([pb, ids]) => {
|
||||
ids.forEach((id, i) => {
|
||||
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
for (const n of noteChanges) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
}
|
||||
|
||||
const moveNote = async (dayId, noteId, direction) => {
|
||||
const merged = getMergedItems(dayId)
|
||||
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
||||
if (idx === -1) return
|
||||
let newSortOrder
|
||||
if (direction === 'up') {
|
||||
if (idx === 0) return
|
||||
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
|
||||
} else {
|
||||
if (idx >= merged.length - 1) return
|
||||
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
|
||||
}
|
||||
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const startEditTitle = (day, e) => {
|
||||
e.stopPropagation()
|
||||
setEditTitle(day.title || '')
|
||||
setEditingDayId(day.id)
|
||||
}
|
||||
|
||||
const saveTitle = async (dayId) => {
|
||||
setEditingDayId(null)
|
||||
await onUpdateDayTitle?.(dayId, editTitle.trim())
|
||||
}
|
||||
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng).map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
|
||||
setIsCalculating(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, transportMode)
|
||||
// Luftlinien zwischen Wegpunkten anzeigen
|
||||
const lineCoords = waypoints.map(p => [p.lat, p.lng])
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.({ ...result, coordinates: lineCoords })
|
||||
} catch { toast.error(t('dayplan.toast.routeError')) }
|
||||
finally { setIsCalculating(false) }
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
if (da.length < 3) return
|
||||
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(withCoords)
|
||||
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
|
||||
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
|
||||
await onReorder(selectedDayId, reorderedIds)
|
||||
toast.success(t('dayplan.toast.routeOptimized'))
|
||||
}
|
||||
|
||||
const handleGoogleMaps = () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error(t('dayplan.toast.noGeoPlaces'))
|
||||
}
|
||||
|
||||
const handleDropOnDay = (e, dayId) => {
|
||||
e.preventDefault()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
window.__dragData = null
|
||||
}
|
||||
|
||||
const handleDropOnRow = (e, dayId, toIdx) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const placeId = e.dataTransfer.getData('placeId')
|
||||
const fromAssignmentId = e.dataTransfer.getData('assignmentId')
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (fromAssignmentId) {
|
||||
const da = getDayAssignments(dayId)
|
||||
const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId)
|
||||
if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return }
|
||||
const ids = da.map(a => a.id)
|
||||
const [removed] = ids.splice(fromIdx, 1)
|
||||
ids.splice(toIdx, 0, removed)
|
||||
onReorder(dayId, ids)
|
||||
}
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const totalCost = days.reduce((s, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Reise-Titel */}
|
||||
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
title={t('dayplan.pdfTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
const da = getDayAssignments(day.id)
|
||||
const cost = dayTotalCost(day.id, assignments, currency)
|
||||
const formattedDate = formatDate(day.date, locale)
|
||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||
const isDragTarget = dragOverDayId === day.id
|
||||
const merged = getMergedItems(day.id)
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
onClick={() => onSelectDay(isSelected ? null : day.id)}
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 14px 11px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
|
||||
transition: 'background 0.12s',
|
||||
userSelect: 'none',
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
outlineOffset: -2,
|
||||
borderRadius: isDragTarget ? 8 : 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
>
|
||||
{/* Tages-Badge */}
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingDayId === day.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
onBlur={() => saveTitle(day.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', border: 'none', outline: 'none',
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
|
||||
background: 'transparent', padding: 0, fontFamily: 'inherit',
|
||||
borderBottom: '1.5px solid var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
||||
{day.date && anyGeoPlace && (() => {
|
||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
title={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Aufgeklappte Orte + Notizen */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
style={{ background: 'var(--bg-hover)' }}
|
||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
if (m.length === 0) return
|
||||
const lastItem = m[m.length - 1]
|
||||
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
|
||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
|
||||
}}
|
||||
>
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
|
||||
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
|
||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('dayplan.emptyDay')}</span>
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
|
||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = place.id === selectedPlaceId
|
||||
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
|
||||
const isConfirmed = place.reservation_status === 'confirmed'
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const moveUp = (e) => {
|
||||
e.stopPropagation()
|
||||
if (placeIdx === 0) return
|
||||
const ids = placeItems.map(i => i.data.id)
|
||||
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
|
||||
onReorder(day.id, ids)
|
||||
}
|
||||
const moveDown = (e) => {
|
||||
e.stopPropagation()
|
||||
if (placeIdx === placeItems.length - 1) return
|
||||
const ids = placeItems.map(i => i.data.id)
|
||||
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
|
||||
onReorder(day.id, ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
||||
setDraggingId(assignment.id)
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
cursor: 'pointer',
|
||||
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
borderLeft: hasReservation
|
||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
||||
: '3px solid transparent',
|
||||
transition: 'background 0.1s',
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
<PlaceAvatar place={place} category={cat} size={28} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} title={cat.name} style={{ flexShrink: 0 }} />
|
||||
})()}
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
{place.place_time && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && !hasReservation && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasReservation && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
|
||||
{isConfirmed ? <><CheckCircle2 size={10} />
|
||||
{place.reservation_datetime
|
||||
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
|
||||
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
|
||||
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
const NoteIcon = getNoteIcon(note.icon)
|
||||
const noteIdx = idx
|
||||
return (
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||
if (fromNoteId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 2px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{note.text}
|
||||
</span>
|
||||
{note.time && (
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
)}
|
||||
{/* Drop-Indikator am Listenende */}
|
||||
{!!draggingId && dropTargetKey === `end-${day.id}` && (
|
||||
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
|
||||
)}
|
||||
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
|
||||
{TRANSPORT_MODES.map(m => (
|
||||
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
|
||||
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
|
||||
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
|
||||
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{routeInfo && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
opacity: isCalculating ? 0.6 : 1,
|
||||
}}>
|
||||
<Navigation size={12} strokeWidth={2} />
|
||||
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
|
||||
</button>
|
||||
<button onClick={handleOptimize} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<RotateCcw size={12} strokeWidth={2} />
|
||||
{t('dayplan.optimize')}
|
||||
</button>
|
||||
<button onClick={handleGoogleMaps} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ExternalLink size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
||||
<div key={dayId} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => cancelNote(Number(dayId))}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
|
||||
</div>
|
||||
{/* Icon-Auswahl */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
||||
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
||||
title={id}
|
||||
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
ref={noteInputRef}
|
||||
type="text"
|
||||
value={ui.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ui.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteSubtitle')}
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
))}
|
||||
|
||||
{/* Budget-Fußzeile */}
|
||||
{totalCost > 0 && (
|
||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react'
|
||||
import { CalendarDays, MapPin, Plus } from 'lucide-react'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function dayTotal(dayId, assignments) {
|
||||
const dayAssignments = assignments[String(dayId)] || []
|
||||
return dayAssignments.reduce((sum, a) => {
|
||||
const cost = parseFloat(a.place?.cost) || 0
|
||||
return sum + cost
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
|
||||
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Tagesplan</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p>
|
||||
</div>
|
||||
|
||||
{/* All places overview option */}
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
|
||||
selectedDayId === null
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
Alle Orte
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Gesamtübersicht</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Day list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-400">Noch keine Tage</p>
|
||||
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const cost = dayTotal(day.id, assignments)
|
||||
const placeCount = dayAssignments.length
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => onSelectDay(day.id)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{day.date && (
|
||||
<p className="text-xs text-gray-400 mt-1 ml-0.5">
|
||||
{formatDate(day.date)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{placeCount > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'}
|
||||
</span>
|
||||
)}
|
||||
{cost > 0 && (
|
||||
<span className="text-xs text-emerald-600 font-medium">
|
||||
{cost.toFixed(0)} {currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather for this day */}
|
||||
{day.date && isSelected && (
|
||||
<div className="mt-2">
|
||||
<WeatherWidget date={day.date} compact />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget summary footer */}
|
||||
{totalCost > 0 && (
|
||||
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{totalCost.toFixed(2)} {currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { MapPin, DollarSign, Check } from 'lucide-react'
|
||||
|
||||
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `place-${place.id}`,
|
||||
data: {
|
||||
type: 'place',
|
||||
place,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: isDragging ? 999 : undefined,
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
|
||||
transition-all select-none
|
||||
${isDragging
|
||||
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
|
||||
}
|
||||
`}
|
||||
onClick={e => {
|
||||
if (!isDragging && onEdit) {
|
||||
e.stopPropagation()
|
||||
onEdit(place)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Category left border accent */}
|
||||
{place.category && (
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pl-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
|
||||
{place.name}
|
||||
</p>
|
||||
{isAssigned && (
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
|
||||
<Check className="w-3 h-3 text-emerald-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{place.price != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{place.tags.slice(0, 3).map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{place.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
|
||||
const RESERVATION_STATUS = {
|
||||
none: { label: 'Keine Reservierung', color: 'gray' },
|
||||
pending: { label: 'Res. ausstehend', color: 'yellow' },
|
||||
confirmed: { label: 'Bestätigt', color: 'green' },
|
||||
}
|
||||
|
||||
export function PlaceDetailPanel({
|
||||
place, categories, tags, selectedDayId, dayAssignments,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
}) {
|
||||
const [googlePhoto, setGooglePhoto] = useState(null)
|
||||
const [photoAttribution, setPhotoAttribution] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!place?.google_place_id || place?.image_url) {
|
||||
setGooglePhoto(null)
|
||||
return
|
||||
}
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
setGooglePhoto(data.photoUrl || null)
|
||||
setPhotoAttribution(data.attribution || null)
|
||||
})
|
||||
.catch(() => setGooglePhoto(null))
|
||||
}, [place?.google_place_id, place?.image_url])
|
||||
|
||||
if (!place) return null
|
||||
|
||||
const displayPhoto = place.image_url || googlePhoto
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const placeTags = (place.tags || []).map(t =>
|
||||
tags?.find(tg => tg.id === (t.id || t)) || t
|
||||
).filter(Boolean)
|
||||
|
||||
const assignmentInDay = selectedDayId
|
||||
? dayAssignments?.find(a => a.place?.id === place.id)
|
||||
: null
|
||||
|
||||
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Image */}
|
||||
{displayPhoto ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={displayPhoto}
|
||||
alt={place.name}
|
||||
className="w-full h-40 object-cover"
|
||||
onError={e => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
{photoAttribution && !place.image_url && (
|
||||
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
|
||||
© {photoAttribution}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-24 flex items-center justify-center relative"
|
||||
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
|
||||
>
|
||||
<span className="text-4xl">{category?.icon || '📍'}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Name + category */}
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
|
||||
{category && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
|
||||
style={{ backgroundColor: `${category.color}20`, color: category.color }}
|
||||
>
|
||||
{category.icon} {category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick info row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{place.place_time && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</div>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
|
||||
<Euro className="w-3 h-3" />
|
||||
{place.price} {place.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<div className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
|
||||
<span>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinates */}
|
||||
{place.lat && place.lng && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex gap-2">
|
||||
{place.website && (
|
||||
<a
|
||||
href={place.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
{place.phone && (
|
||||
<a
|
||||
href={`tel:${place.phone}`}
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<Phone className="w-3 h-3" />
|
||||
{place.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{place.description && (
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{placeTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{placeTags.map((tag, i) => (
|
||||
<span
|
||||
key={tag.id || i}
|
||||
className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation status */}
|
||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
||||
<div className={`rounded-lg px-3 py-2 border ${
|
||||
place.reservation_status === 'confirmed'
|
||||
? 'bg-emerald-50 border-emerald-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}>
|
||||
<div className={`text-xs font-semibold ${
|
||||
place.reservation_status === 'confirmed' ? 'text-emerald-700' : 'text-yellow-700'
|
||||
}`}>
|
||||
{place.reservation_status === 'confirmed' ? '✅' : '⏳'} {status.label}
|
||||
</div>
|
||||
{place.reservation_datetime && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{formatDateTime(place.reservation_datetime)}
|
||||
</div>
|
||||
)}
|
||||
{place.reservation_notes && (
|
||||
<p className="text-xs text-gray-600 mt-1">{place.reservation_notes}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day assignment actions */}
|
||||
{selectedDayId && (
|
||||
<div className="pt-1">
|
||||
{assignmentInDay ? (
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
Aus Tag entfernen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAssignToDay(place.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Zum Tag hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Delete */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
const TRANSPORT_MODES = [
|
||||
{ value: 'walking', labelKey: 'places.transport.walking' },
|
||||
{ value: 'driving', labelKey: 'places.transport.driving' },
|
||||
{ value: 'cycling', labelKey: 'places.transport.cycling' },
|
||||
{ value: 'transit', labelKey: 'places.transport.transit' },
|
||||
]
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
lat: '',
|
||||
lng: '',
|
||||
category_id: '',
|
||||
place_time: '',
|
||||
notes: '',
|
||||
transport_mode: 'walking',
|
||||
reservation_status: 'none',
|
||||
reservation_notes: '',
|
||||
reservation_datetime: '',
|
||||
website: '',
|
||||
}
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen, onClose, onSave, place, tripId, categories,
|
||||
onCategoryCreated,
|
||||
}) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
setForm({
|
||||
name: place.name || '',
|
||||
description: place.description || '',
|
||||
address: place.address || '',
|
||||
lat: place.lat || '',
|
||||
lng: place.lng || '',
|
||||
category_id: place.category_id || '',
|
||||
place_time: place.place_time || '',
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
reservation_status: place.reservation_status || 'none',
|
||||
reservation_notes: place.reservation_notes || '',
|
||||
reservation_datetime: place.reservation_datetime || '',
|
||||
website: place.website || '',
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
}
|
||||
}, [place, isOpen])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleMapsSearch = async () => {
|
||||
if (!mapsSearch.trim()) return
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err) {
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMapsResult = (result) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
name: result.name || prev.name,
|
||||
address: result.address || prev.address,
|
||||
lat: result.lat || prev.lat,
|
||||
lng: result.lng || prev.lng,
|
||||
google_place_id: result.google_place_id || prev.google_place_id,
|
||||
}))
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
try {
|
||||
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' })
|
||||
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
} catch (err) {
|
||||
toast.error(t('places.categoryCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t('places.nameRequired'))
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
...form,
|
||||
lat: form.lat ? parseFloat(form.lat) : null,
|
||||
lng: form.lng ? parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('places.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Google Maps Search */}
|
||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={mapsSearch}
|
||||
onChange={e => setMapsSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
|
||||
placeholder={t('places.mapsSearchPlaceholder')}
|
||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMapsSearch}
|
||||
disabled={isSearchingMaps}
|
||||
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
|
||||
>
|
||||
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{mapsResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
|
||||
{mapsResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-slate-50 border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
<div className="text-xs text-slate-500 truncate">{result.address}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => handleChange('name', e.target.value)}
|
||||
required
|
||||
placeholder={t('places.formNamePlaceholder')}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formDescription')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => handleChange('description', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.formDescriptionPlaceholder')}
|
||||
className="form-input" style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address + Coordinates */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.address}
|
||||
onChange={e => handleChange('address', e.target.value)}
|
||||
placeholder={t('places.formAddressPlaceholder')}
|
||||
className="form-input"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={e => handleChange('lat', e.target.value)}
|
||||
placeholder={t('places.formLat')}
|
||||
className="form-input"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lng}
|
||||
onChange={e => handleChange('lng', e.target.value)}
|
||||
placeholder={t('places.formLng')}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formCategory')}</label>
|
||||
{!showNewCategory ? (
|
||||
<div className="flex gap-2">
|
||||
<CustomSelect
|
||||
value={form.category_id}
|
||||
onChange={value => handleChange('category_id', value)}
|
||||
placeholder={t('places.noCategory')}
|
||||
options={[
|
||||
{ value: '', label: t('places.noCategory') },
|
||||
...(categories || []).map(c => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
})),
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
placeholder={t('places.categoryNamePlaceholder')}
|
||||
className="form-input" style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" onClick={handleCreateCategory} className="bg-slate-900 text-white px-3 rounded-lg hover:bg-slate-700 text-sm">
|
||||
OK
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowNewCategory(false)} className="text-gray-500 px-2 text-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={v => handleChange('place_time', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formWebsite')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.website}
|
||||
onChange={e => handleChange('website', e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reservation */}
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
|
||||
<div className="flex gap-2">
|
||||
{['none', 'pending', 'confirmed'].map(status => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => handleChange('reservation_status', status)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||
form.reservation_status === status
|
||||
? status === 'confirmed' ? 'bg-emerald-600 text-white'
|
||||
: status === 'pending' ? 'bg-yellow-500 text-white'
|
||||
: 'bg-gray-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.reservation_status !== 'none' && (
|
||||
<>
|
||||
<CustomDateTimePicker
|
||||
value={form.reservation_datetime}
|
||||
onChange={v => handleChange('reservation_datetime', v)}
|
||||
/>
|
||||
<textarea
|
||||
value={form.reservation_notes}
|
||||
onChange={e => handleChange('reservation_notes', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.reservationNotesPlaceholder')}
|
||||
className="form-input" style={{ resize: 'none' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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}
|
||||
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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
function getSessionCache(key) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : undefined
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
function setSessionCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
function useGoogleDetails(googlePlaceId, language) {
|
||||
const [details, setDetails] = useState(null)
|
||||
const cacheKey = `gdetails_${googlePlaceId}_${language}`
|
||||
useEffect(() => {
|
||||
if (!googlePlaceId) { setDetails(null); return }
|
||||
// In-memory cache (fastest)
|
||||
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
||||
// sessionStorage cache (survives reload)
|
||||
const cached = getSessionCache(cacheKey)
|
||||
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
||||
// Fetch from API
|
||||
mapsApi.details(googlePlaceId, language).then(data => {
|
||||
detailsCache.set(cacheKey, data.place)
|
||||
setSessionCache(cacheKey, data.place)
|
||||
setDetails(data.place)
|
||||
}).catch(() => {})
|
||||
}, [googlePlaceId, language])
|
||||
return details
|
||||
}
|
||||
|
||||
function getWeekdayIndex(dateStr) {
|
||||
// weekdayDescriptions[0] = Monday … [6] = Sunday
|
||||
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
|
||||
const jsDay = d.getDay()
|
||||
return jsDay === 0 ? 6 : jsDay - 1
|
||||
}
|
||||
|
||||
function convertHoursLine(line, timeFormat) {
|
||||
if (!line) return ''
|
||||
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
|
||||
|
||||
if (timeFormat === '12h' && !hasAmPm) {
|
||||
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
|
||||
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
|
||||
const hour = parseInt(h)
|
||||
if (isNaN(hour)) return match
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
||||
return `${h12}:${m} ${period}`
|
||||
})
|
||||
}
|
||||
if (timeFormat !== '12h' && hasAmPm) {
|
||||
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
|
||||
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
|
||||
let hour = parseInt(h)
|
||||
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
|
||||
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
|
||||
return `${String(hour).padStart(2, '0')}:${m}`
|
||||
})
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
return locale?.startsWith('de') ? `${str} Uhr` : str
|
||||
} catch { return timeStr }
|
||||
}
|
||||
|
||||
function formatReservationDatetime(dt, locale, timeFormat) {
|
||||
if (!dt) return null
|
||||
try {
|
||||
const d = new Date(dt)
|
||||
if (isNaN(d)) return dt
|
||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
|
||||
return `${datePart}, ${timePart}`
|
||||
} catch { return dt }
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, assignments,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload,
|
||||
}) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
const googleDetails = useGoogleDetails(place?.google_place_id, language)
|
||||
|
||||
if (!place) return null
|
||||
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
||||
|
||||
const openingHours = googleDetails?.opening_hours || null
|
||||
const openNow = googleDetails?.open_now ?? null
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
if (!selectedFiles.length || !onFileUpload) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
for (const file of selectedFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', place.id)
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
setFilesExpanded(true)
|
||||
} catch (err) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}, [onFileUpload, place.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(800px, calc(100vw - 32px))',
|
||||
zIndex: 50,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Avatar with open/closed ring + tag */}
|
||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||
<div style={{
|
||||
borderRadius: '50%', padding: 2.5,
|
||||
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
|
||||
}}>
|
||||
<PlaceAvatar place={place} category={category} size={52} />
|
||||
</div>
|
||||
{openNow !== null && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
|
||||
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
|
||||
color: 'white',
|
||||
background: openNow ? '#16a34a' : '#dc2626',
|
||||
padding: '1.5px 7px', borderRadius: 99,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
{openNow ? t('inspector.opened') : t('inspector.closed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
|
||||
{category && (() => {
|
||||
const CatIcon = getCategoryIcon(category.icon)
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, fontWeight: 500,
|
||||
color: category.color || '#6b7280',
|
||||
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
|
||||
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
|
||||
padding: '2px 8px', borderRadius: 99,
|
||||
}}>
|
||||
<CatIcon size={10} />
|
||||
{category.name}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{place.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
||||
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.place_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
|
||||
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.lat && place.lng && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
>
|
||||
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{googleDetails?.rating && (() => {
|
||||
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
||||
return (
|
||||
<Chip
|
||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||
text={<>
|
||||
{googleDetails.rating.toFixed(1)}
|
||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
|
||||
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
||||
</>}
|
||||
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
{place.price > 0 && (
|
||||
<Chip icon={<Euro size={12} />} text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Telefon */}
|
||||
{place.phone && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<a href={`tel:${place.phone}`}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||
<Phone size={12} /> {place.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description + Reservation in one box */}
|
||||
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{(place.description || place.notes) && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px',
|
||||
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
|
||||
}}>
|
||||
{place.description || place.notes}
|
||||
</p>
|
||||
)}
|
||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
||||
{place.reservation_status === 'confirmed'
|
||||
? <CheckCircle2 size={12} color="#059669" />
|
||||
: <AlertCircle size={12} color="#d97706" />
|
||||
}
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
|
||||
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
|
||||
</span>
|
||||
{(place.reservation_datetime || place.place_time) && (
|
||||
<>
|
||||
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{place.reservation_datetime
|
||||
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
|
||||
: formatTime(place.place_time, locale, timeFormat)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{place.reservation_notes && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opening hours */}
|
||||
{openingHours && openingHours.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => setHoursExpanded(h => !h)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Clock size={13} color="#9ca3af" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
||||
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
|
||||
</span>
|
||||
</div>
|
||||
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
|
||||
</button>
|
||||
{hoursExpanded && (
|
||||
<div style={{ padding: '0 12px 10px' }}>
|
||||
{openingHours.map((line, i) => (
|
||||
<div key={i} style={{
|
||||
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: i === weekdayIndex ? 600 : 400,
|
||||
padding: '2px 0',
|
||||
}}>{convertHoursLine(line, timeFormat)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Files section */}
|
||||
{(placeFiles.length > 0 || onFileUpload) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => setFilesExpanded(f => !f)}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
<FileText size={13} color="#9ca3af" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
||||
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
|
||||
</span>
|
||||
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
|
||||
</button>
|
||||
{onFileUpload && (
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
|
||||
{isUploading ? (
|
||||
<span style={{ fontSize: 11 }}>…</span>
|
||||
) : (
|
||||
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
<a
|
||||
href={`/uploads/files/${f.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
|
||||
) : (
|
||||
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||
)
|
||||
)}
|
||||
{googleDetails?.google_maps_url && (
|
||||
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||
)}
|
||||
{place.website && (
|
||||
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
|
||||
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flexShrink: 0 }}>{icon}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }) {
|
||||
const base = {
|
||||
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
|
||||
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
|
||||
danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' },
|
||||
}
|
||||
const s = base[variant] || base.ghost
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '6px 12px', borderRadius: 10, minHeight: 30,
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', transition: 'background 0.15s, opacity 0.15s',
|
||||
background: s.background, color: s.color, border: s.border,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = s.hoverBg}
|
||||
onMouseLeave={e => e.currentTarget.style.background = s.background}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import DraggablePlaceCard from './DraggablePlaceCard'
|
||||
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
export default function PlacesPanel({
|
||||
places,
|
||||
categories,
|
||||
tags,
|
||||
assignments,
|
||||
tripId,
|
||||
onAddPlace,
|
||||
onEditPlace,
|
||||
hasMapKey,
|
||||
onSearchMaps,
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState([])
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Get set of assigned place IDs (for any day)
|
||||
const assignedPlaceIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
Object.values(assignments || {}).forEach(dayAssignments => {
|
||||
dayAssignments.forEach(a => {
|
||||
if (a.place?.id) ids.add(a.place.id)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
}, [assignments])
|
||||
|
||||
const filteredPlaces = useMemo(() => {
|
||||
return places.filter(place => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
if (!place.name.toLowerCase().includes(q) &&
|
||||
!place.address?.toLowerCase().includes(q) &&
|
||||
!place.description?.toLowerCase().includes(q)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
|
||||
return false
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
const placeTags = (place.tags || []).map(t => t.id)
|
||||
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [places, search, selectedCategory, selectedTags])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setSelectedCategory('')
|
||||
setSelectedTags([])
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border-r border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-slate-800">
|
||||
Places
|
||||
<span className="ml-1.5 text-xs font-normal text-slate-400">
|
||||
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{hasMapKey && (
|
||||
<button
|
||||
onClick={onSearchMaps}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
|
||||
title="Search Google Maps"
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'text-slate-700 bg-slate-50'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Filters"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search places..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Tag filters */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add place button */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Place
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
{places.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No places yet</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
|
||||
>
|
||||
+ Add your first place
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No matches found</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredPlaces.map(place => (
|
||||
<DraggablePlaceCard
|
||||
key={place.id}
|
||||
place={place}
|
||||
isAssigned={assignedPlaceIds.has(place.id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
|
||||
const filtered = places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Kopfbereich */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
}}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('places.search')}
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
border: 'none', background: 'var(--bg-tertiary)', fontSize: 12, color: 'var(--text-primary)',
|
||||
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex' }}>
|
||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kategoriefilter */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CustomSelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
placeholder={t('places.allCategories')}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: '', label: t('places.allCategories') },
|
||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const inDay = isAssignedToSelectedDay(place.id)
|
||||
const isPlanned = plannedIds.has(place.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile && days?.length > 0) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: 'grab',
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => setDayPickerPlace(null)}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
disabled={alreadyAssigned}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: alreadyAssigned ? 'default' : 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
opacity: alreadyAssigned ? 0.4 : 1, transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!alreadyAssigned) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
||||
}}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{alreadyAssigned && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import {
|
||||
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
|
||||
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
|
||||
CalendarDays, FileText, Check, Pencil, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import FileManager from '../Files/FileManager'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'orte', label: 'Orte' },
|
||||
{ id: 'reservierungen', label: 'Buchungen' },
|
||||
{ id: 'packliste', label: 'Packliste' },
|
||||
{ id: 'dokumente', label: 'Dokumente' },
|
||||
]
|
||||
|
||||
const TRANSPORT_MODES = [
|
||||
{ value: 'driving', label: 'Auto', icon: '🚗' },
|
||||
{ value: 'walking', label: 'Fuß', icon: '🚶' },
|
||||
{ value: 'cycling', label: 'Rad', icon: '🚲' },
|
||||
]
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch { return dt }
|
||||
}
|
||||
|
||||
export default function PlannerSidebar({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDayId, selectedPlaceId,
|
||||
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeSegment, setActiveSegment] = useState('plan')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [transportMode, setTransportMode] = useState('driving')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [expandedDays, setExpandedDays] = useState(new Set())
|
||||
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
|
||||
const [noteUi, setNoteUi] = useState({})
|
||||
const noteInputRef = useRef(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const placesListRef = useRef(null)
|
||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!placesListRef.current) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPlacesListHeight(entry.contentRect.height)
|
||||
})
|
||||
ro.observe(placesListRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [activeSegment])
|
||||
|
||||
// Auto-expand selected day
|
||||
useEffect(() => {
|
||||
if (selectedDayId) {
|
||||
setExpandedDays(prev => new Set([...prev, selectedDayId]))
|
||||
}
|
||||
}, [selectedDayId])
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setExpandedDays(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dayId)) next.delete(dayId)
|
||||
else next.add(dayId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
|
||||
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
|
||||
|
||||
const filteredPlaces = useMemo(() => places.filter(p => {
|
||||
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchSearch && matchCat
|
||||
}), [places, search, categoryFilter])
|
||||
|
||||
const isAssignedToDay = (placeId) =>
|
||||
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
const totalCost = days.reduce((sum, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
// Get representative location for a day (first place with coords)
|
||||
const getDayLocation = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const p = da.find(a => a.place?.lat && a.place?.lng)
|
||||
return p ? { lat: p.place.lat, lng: p.place.lng } : null
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = selectedDayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
if (waypoints.length < 2) {
|
||||
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
|
||||
return
|
||||
}
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, transportMode)
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success('Route berechnet')
|
||||
} catch {
|
||||
toast.error('Route konnte nicht berechnet werden')
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || selectedDayAssignments.length < 3) return
|
||||
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(withCoords)
|
||||
const reorderedIds = optimized
|
||||
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
|
||||
.filter(Boolean)
|
||||
// Append assignments without coordinates at end
|
||||
for (const a of selectedDayAssignments) {
|
||||
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
|
||||
}
|
||||
await onReorder(selectedDayId, reorderedIds)
|
||||
toast.success('Route optimiert')
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(ps)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error('Keine Orte mit Koordinaten vorhanden')
|
||||
}
|
||||
|
||||
const handleMoveUp = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === 0) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === da.length - 1) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
// Merge place assignments + day notes into a single sorted list
|
||||
const getMergedDayItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
const openAddNote = (dayId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note) => {
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNote = async (dayId, noteId) => {
|
||||
try {
|
||||
await tripStore.deleteDayNote(tripId, dayId, noteId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveUp = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx <= 0) return
|
||||
const newSortOrder = idx >= 2
|
||||
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
|
||||
: merged[idx - 1].sortKey - 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveDown = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx === -1 || idx >= merged.length - 1) return
|
||||
const newSortOrder = idx < merged.length - 2
|
||||
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
|
||||
: merged[idx + 1].sortKey + 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success('Reservierung aktualisiert')
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success('Reservierung hinzugefügt')
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm('Reservierung löschen?')) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success('Reservierung gelöscht')
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
|
||||
|
||||
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<button onClick={onEditTrip} className="w-full text-left group">
|
||||
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
|
||||
{trip?.title}
|
||||
</h1>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{trip.start_date && formatShortDate(trip.start_date)}
|
||||
{trip.start_date && trip.end_date && ' – '}
|
||||
{trip.end_date && formatShortDate(trip.end_date)}
|
||||
{days.length > 0 && ` · ${days.length} Tage`}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
|
||||
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
|
||||
{SEGMENTS.map(seg => (
|
||||
<button
|
||||
key={seg.id}
|
||||
onClick={() => setActiveSegment(seg.id)}
|
||||
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
|
||||
activeSegment === seg.id
|
||||
? 'bg-white shadow-sm text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
|
||||
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
|
||||
}`}>
|
||||
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
Alle Orte
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-400">Noch keine Tage geplant</p>
|
||||
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
||||
Reise bearbeiten →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
const da = getDayAssignments(day.id)
|
||||
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
const loc = getDayLocation(day.id)
|
||||
const merged = getMergedDayItems(day.id)
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} className="border-b border-gray-50">
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
|
||||
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectDay(day.id)
|
||||
if (!isExpanded) toggleDay(day.id)
|
||||
}}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</p>
|
||||
{da.length > 0 && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
|
||||
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
|
||||
{day.date && loc && (
|
||||
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
|
||||
title="Notiz hinzufügen"
|
||||
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
|
||||
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-gray-50/40">
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div className="px-4 py-4 text-center">
|
||||
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
|
||||
<button
|
||||
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
||||
className="mt-1 text-xs text-slate-700"
|
||||
>
|
||||
+ Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100/60">
|
||||
{merged.map((item, idx) => {
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = place.id === selectedPlaceId
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`place-${assignment.id}`}
|
||||
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
|
||||
}`}
|
||||
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{place.name}
|
||||
</p>
|
||||
{(place.description || place.notes) && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
|
||||
{place.description || place.notes}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{place.place_time && (
|
||||
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
|
||||
)}
|
||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
place.reservation_status === 'confirmed'
|
||||
? 'bg-emerald-50 text-emerald-600'
|
||||
: 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
|
||||
disabled={placeIdx === 0}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
|
||||
disabled={placeIdx === placeItems.length - 1}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const note = item.data
|
||||
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
|
||||
if (isEditingThis) {
|
||||
return (
|
||||
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder="Zeit (optional)"
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder="Notiz…"
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> Speichern
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.time && (
|
||||
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
|
||||
)}
|
||||
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dayNoteUi?.mode === 'add' && (
|
||||
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder="Zeit (optional)"
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> Hinzufügen
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dayNoteUi && (
|
||||
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
|
||||
<button
|
||||
onClick={() => openAddNote(day.id)}
|
||||
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Notiz hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route tools — only for the selected day */}
|
||||
{isSelected && da.length >= 2 && (
|
||||
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
|
||||
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
|
||||
{TRANSPORT_MODES.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setTransportMode(m.value)}
|
||||
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
|
||||
transportMode === m.value
|
||||
? 'bg-white shadow-sm text-gray-900 font-medium'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{m.icon} {m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? 'Berechne...' : 'Route'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOptimizeRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Optimieren
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
In Google Maps öffnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{totalCost > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ORTE ── */}
|
||||
{activeSegment === 'orte' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className="p-3 space-y-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Orte suchen…"
|
||||
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
|
||||
<X className="w-3.5 h-3.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Neu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">📍</span>
|
||||
<p className="text-sm">Keine Orte gefunden</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
||||
Ersten Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
|
||||
<FixedSizeList
|
||||
height={placesListHeight}
|
||||
itemCount={filteredPlaces.length}
|
||||
itemSize={68}
|
||||
overscanCount={10}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const place = filteredPlaces[index]
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
+ Tag
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
|
||||
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── RESERVIERUNGEN ── */}
|
||||
{activeSegment === 'reservierungen' && (
|
||||
<div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
Reservierungen
|
||||
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🎫</span>
|
||||
<p className="text-sm">Keine Reservierungen</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-2.5">
|
||||
{filteredReservations.map(r => (
|
||||
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
|
||||
{r.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(r.reservation_time)}
|
||||
</div>
|
||||
)}
|
||||
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
|
||||
{r.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
|
||||
# {r.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>✏️</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(r.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PACKLISTE ── */}
|
||||
{activeSegment === 'packliste' && (
|
||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||
)}
|
||||
|
||||
{/* ── DOKUMENTE ── */}
|
||||
{activeSegment === 'dokumente' && (
|
||||
<FileManager tripId={tripId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── INSPECTOR OVERLAY ── */}
|
||||
{selectedPlace && (
|
||||
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={selectedDayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', location: '', confirmation_number: '',
|
||||
notes: '', day_id: '', place_id: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
|
||||
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type: reservation.type || 'other',
|
||||
status: reservation.status || 'pending',
|
||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
day_id: reservation.day_id || '',
|
||||
place_id: reservation.place_id || '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', location: '', confirmation_number: '',
|
||||
notes: '', day_id: selectedDayId || '', place_id: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId])
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saved = await onSave({
|
||||
...form,
|
||||
day_id: form.day_id || null,
|
||||
place_id: form.place_id || null,
|
||||
})
|
||||
// Upload pending files for newly created reservations
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('reservation_id', saved.id)
|
||||
fd.append('description', form.title)
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (reservation?.id) {
|
||||
// Existing reservation — upload immediately
|
||||
setUploadingFile(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('reservation_id', reservation.id)
|
||||
fd.append('description', reservation.title)
|
||||
await onFileUpload(fd)
|
||||
toast.success(t('reservations.toast.fileUploaded'))
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.uploadError'))
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
} else {
|
||||
// New reservation — stage locally
|
||||
setPendingFiles(prev => [...prev, file])
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '6px 11px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={12} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
||||
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Date/Time + Status */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
||||
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
||||
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Confirmation number */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Linked day + place */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.day')}</label>
|
||||
<CustomSelect
|
||||
value={form.day_id}
|
||||
onChange={value => set('day_id', value)}
|
||||
placeholder={t('reservations.noDay')}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.noDay') },
|
||||
...(days || []).map(day => ({
|
||||
value: day.id,
|
||||
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
|
||||
})),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.place')}</label>
|
||||
<CustomSelect
|
||||
value={form.place_id}
|
||||
onChange={value => set('place_id', value)}
|
||||
placeholder={t('reservations.noPlace')}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.noPlace') },
|
||||
...(places || []).map(place => ({
|
||||
value: place.id,
|
||||
label: place.name,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* File upload — always visible */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
|
||||
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<Paperclip size={13} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<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: 13, 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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import {
|
||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
|
||||
ExternalLink, BookMarked, Lightbulb,
|
||||
} from 'lucide-react'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
function typeIcon(type) {
|
||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
|
||||
}
|
||||
function typeLabelKey(type) {
|
||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
|
||||
}
|
||||
|
||||
function formatDateTimeWithLocale(str, locale, timeFormat) {
|
||||
if (!str) return null
|
||||
const d = new Date(str)
|
||||
if (isNaN(d)) return str
|
||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
const h = d.getHours(), m = d.getMinutes()
|
||||
let timePart
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
} else {
|
||||
timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
if (locale?.startsWith('de')) timePart += ' Uhr'
|
||||
}
|
||||
return `${datePart} · ${timePart}`
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
||||
|
||||
function PlaceReservationEditModal({ item, tripId, onClose }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
|
||||
place_time: item.place_time || '',
|
||||
reservation_notes: item.notes || '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updatePlace(tripId, item.placeId, {
|
||||
reservation_status: form.reservation_status,
|
||||
reservation_datetime: form.reservation_datetime || null,
|
||||
place_time: form.place_time || null,
|
||||
reservation_notes: form.reservation_notes || null,
|
||||
})
|
||||
toast.success(t('reservations.toast.updated'))
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.reservation_status}
|
||||
onChange={v => set('reservation_status', v)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
||||
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const TypeIcon = typeIcon(r.type)
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<button onClick={handleToggle} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
</button>
|
||||
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
|
||||
</div>
|
||||
)}
|
||||
{r.location && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.confirmation_number && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
|
||||
<Hash size={8} />{r.confirmation_number}
|
||||
</span>
|
||||
)}
|
||||
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
|
||||
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
|
||||
</div>
|
||||
|
||||
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
|
||||
|
||||
{/* Attached files — read-only, upload only via edit modal */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('reservations.showFiles')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceReservationCard({ item, tripId }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [editing, setEditing] = useState(false)
|
||||
const confirmed = item.status === 'confirmed'
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
|
||||
try {
|
||||
await updatePlace(tripId, item.placeId, {
|
||||
reservation_status: 'none',
|
||||
reservation_datetime: null,
|
||||
place_time: null,
|
||||
reservation_notes: null,
|
||||
})
|
||||
toast.success(t('reservations.toast.removed'))
|
||||
} catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||||
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
|
||||
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<span style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
</span>
|
||||
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
||||
{item.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
|
||||
</div>
|
||||
)}
|
||||
{item.place_time && !item.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time}
|
||||
</div>
|
||||
)}
|
||||
{item.location && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
|
||||
}}>
|
||||
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const placeReservations = useMemo(() => {
|
||||
const result = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
for (const assignment of da) {
|
||||
const place = assignment.place
|
||||
if (!place || !place.reservation_status || place.reservation_status === 'none') continue
|
||||
const dayLabel = day.title
|
||||
? day.title
|
||||
: day.date
|
||||
? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
|
||||
: t('dayplan.dayN', { n: day.day_number })
|
||||
result.push({
|
||||
_placeRes: true,
|
||||
id: `place_${day.id}_${place.id}`,
|
||||
placeId: place.id,
|
||||
title: place.name,
|
||||
status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
reservation_time: place.reservation_datetime || null,
|
||||
place_time: place.place_time || null,
|
||||
location: place.address || null,
|
||||
notes: place.reservation_notes || null,
|
||||
dayLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [days, assignments, locale])
|
||||
|
||||
const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')]
|
||||
const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
|
||||
const total = allPending.length + allConfirmed.length
|
||||
|
||||
function renderCard(r) {
|
||||
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
|
||||
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hinweis — einmalig wegklickbar */}
|
||||
{showHint && (
|
||||
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
|
||||
{t('reservations.placeHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
|
||||
{allPending.map(renderCard)}
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
|
||||
{allConfirmed.map(renderCard)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'orte', label: 'Orte', icon: '📍' },
|
||||
{ id: 'tagesplan', label: 'Tagesplan', icon: '📅' },
|
||||
{ id: 'reservierungen', label: 'Reservierungen', icon: '🎫' },
|
||||
{ id: 'packliste', label: 'Packliste', icon: '🎒' },
|
||||
]
|
||||
|
||||
const TRANSPORT_MODES = [
|
||||
{ value: 'driving', label: 'Auto', icon: '🚗' },
|
||||
{ value: 'walking', label: 'Fuß', icon: '🚶' },
|
||||
{ value: 'cycling', label: 'Rad', icon: '🚲' },
|
||||
]
|
||||
|
||||
export function RightPanel({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDay, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('orte')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [transportMode, setTransportMode] = useState('driving')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Filtered places for Orte tab
|
||||
const filteredPlaces = places.filter(p => {
|
||||
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
// Ordered assignments for selected day
|
||||
const dayAssignments = selectedDayId
|
||||
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
: []
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
// Calculate schedule with times
|
||||
const getSchedule = () => {
|
||||
if (!dayAssignments.length) return []
|
||||
let currentTime = null
|
||||
return dayAssignments.map((assignment, idx) => {
|
||||
const place = assignment.place
|
||||
const startTime = place?.place_time || (currentTime ? currentTime : null)
|
||||
const duration = place?.duration_minutes || 60
|
||||
if (startTime) {
|
||||
const [h, m] = startTime.split(':').map(Number)
|
||||
const endMinutes = h * 60 + m + duration
|
||||
const endH = Math.floor(endMinutes / 60) % 24
|
||||
const endM = endMinutes % 60
|
||||
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
|
||||
}
|
||||
return { assignment, startTime, endTime: currentTime }
|
||||
})
|
||||
}
|
||||
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = dayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
|
||||
if (waypoints.length < 2) {
|
||||
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, transportMode)
|
||||
if (result) {
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success('Route berechnet')
|
||||
} else {
|
||||
toast.error('Route konnte nicht berechnet werden')
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Fehler bei der Routenberechnung')
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || dayAssignments.length < 3) return
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(places)
|
||||
const optimizedIds = optimized.map(p => {
|
||||
const a = dayAssignments.find(a => a.place?.id === p.id)
|
||||
return a?.id
|
||||
}).filter(Boolean)
|
||||
await onReorder(selectedDayId, optimizedIds)
|
||||
toast.success('Route optimiert')
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(places)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error('Keine Orte mit Koordinaten vorhanden')
|
||||
}
|
||||
|
||||
const handleMoveUp = async (idx) => {
|
||||
if (idx === 0) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (idx) => {
|
||||
if (idx === dayAssignments.length - 1) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleAddReservation = () => {
|
||||
setEditingReservation(null)
|
||||
setShowReservationModal(true)
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success('Reservierung aktualisiert')
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success('Reservierung hinzugefügt')
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm('Reservierung löschen?')) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success('Reservierung gelöscht')
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Reservations for selected day (or all if no day selected)
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 flex-shrink-0">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === tab.id
|
||||
? 'text-slate-700 border-b-2 border-slate-700'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* ORTE TAB */}
|
||||
{activeTab === 'orte' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Place detail (when selected) */}
|
||||
{selectedPlace && (
|
||||
<div className="border-b border-gray-100">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={dayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & filter */}
|
||||
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Orte suchen..."
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">📍</span>
|
||||
<p className="text-sm">Keine Orte gefunden</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
Ersten Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filteredPlaces.map(place => {
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isInDay = isAssignedToSelectedDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Category color bar */}
|
||||
<div
|
||||
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
|
||||
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isInDay && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded">✓</span>
|
||||
)}
|
||||
{!isInDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
|
||||
>
|
||||
+ Tag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{category && (
|
||||
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
|
||||
)}
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{place.place_time && (
|
||||
<span className="text-xs text-gray-500">🕐 {place.place_time}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAGESPLAN TAB */}
|
||||
{activeTab === 'tagesplan' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedDayId ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
|
||||
<span className="text-4xl mb-3">📅</span>
|
||||
<p className="text-sm text-center">Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Day header */}
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
|
||||
<h3 className="font-semibold text-slate-900 text-sm">
|
||||
Tag {selectedDay?.day_number}
|
||||
{selectedDay?.date && (
|
||||
<span className="font-normal text-slate-700 ml-2">
|
||||
{formatGermanDate(selectedDay.date)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-700 mt-0.5">
|
||||
{dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''}
|
||||
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transport mode */}
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center gap-1 flex-shrink-0">
|
||||
{TRANSPORT_MODES.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setTransportMode(m.value)}
|
||||
className={`flex-1 py-1.5 text-xs rounded-lg flex items-center justify-center gap-1 transition-colors ${
|
||||
transportMode === m.value
|
||||
? 'bg-slate-100 text-slate-900 font-medium'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m.icon} {m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Places list with order */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{dayAssignments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🗺️</span>
|
||||
<p className="text-sm">Noch keine Orte für diesen Tag</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('orte')}
|
||||
className="mt-3 text-slate-700 text-sm hover:underline"
|
||||
>
|
||||
Orte hinzufügen →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
|
||||
return (
|
||||
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
|
||||
{/* Order number */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: category?.color || '#6366f1' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
{/* Place info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{startTime && (
|
||||
<span className="text-xs text-slate-700">🕐 {startTime}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.duration_minutes || 60} Min.
|
||||
</span>
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
|
||||
)}
|
||||
{assignment.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleMoveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(idx)}
|
||||
disabled={idx === dayAssignments.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
|
||||
className="p-1 text-red-400 hover:text-red-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route buttons */}
|
||||
{dayAssignments.length >= 2 && (
|
||||
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? 'Berechne...' : 'Route berechnen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOptimizeRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Optimieren
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
In Google Maps öffnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RESERVIERUNGEN TAB */}
|
||||
{activeTab === 'reservierungen' && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
Reservierungen
|
||||
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddReservation}
|
||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🎫</span>
|
||||
<p className="text-sm">Keine Reservierungen</p>
|
||||
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
Erste Reservierung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-3">
|
||||
{filteredReservations.map(reservation => (
|
||||
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
|
||||
{reservation.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(reservation.reservation_time)}
|
||||
</div>
|
||||
)}
|
||||
{reservation.location && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
|
||||
)}
|
||||
{reservation.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
|
||||
# {reservation.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{reservation.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(reservation.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PACKLISTE TAB */}
|
||||
{activeTab === 'packliste' && (
|
||||
<PackingListPanel
|
||||
tripId={tripId}
|
||||
items={packingItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservation Modal */}
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatGermanDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X } from 'lucide-react'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (trip) {
|
||||
setFormData({
|
||||
title: trip.title || '',
|
||||
description: trip.description || '',
|
||||
start_date: trip.start_date || '',
|
||||
end_date: trip.end_date || '',
|
||||
})
|
||||
setCoverPreview(trip.cover_image || null)
|
||||
} else {
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||
setCoverPreview(null)
|
||||
}
|
||||
setError('')
|
||||
}, [trip, isOpen])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onSave({
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim() || null,
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || t('places.saveError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !trip?.id) return
|
||||
setUploadingCover(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('cover', file)
|
||||
const data = await tripsApi.uploadCover(trip.id, fd)
|
||||
setCoverPreview(data.cover_image)
|
||||
onCoverUpdate?.(trip.id, data.cover_image)
|
||||
toast.success(t('dashboard.coverSaved'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.coverUploadError'))
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveCover = async () => {
|
||||
if (!trip?.id) return
|
||||
try {
|
||||
await tripsApi.update(trip.id, { cover_image: null })
|
||||
setCoverPreview(null)
|
||||
onCoverUpdate?.(trip.id, null)
|
||||
} catch {
|
||||
toast.error(t('dashboard.coverRemoveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const update = (field, value) => setFormData(prev => {
|
||||
const next = { ...prev, [field]: value }
|
||||
// Auto-adjust end date when start date changes
|
||||
if (field === 'start_date' && value) {
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
// If no end date or end date is before new start, set end = start
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
// Preserve trip duration: shift end date by same delta
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
const newEnd = new Date(value + 'T00:00:00')
|
||||
newEnd.setDate(newEnd.getDate() + duration)
|
||||
next.end_date = newEnd.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={isLoading}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
{isLoading
|
||||
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('common.saving')}</>
|
||||
: isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Cover image — only for existing trips */}
|
||||
{isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||
{coverPreview ? (
|
||||
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
||||
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
|
||||
</button>
|
||||
<button type="button" onClick={handleRemoveCover}
|
||||
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
|
||||
</label>
|
||||
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
|
||||
</label>
|
||||
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formData.start_date && !formData.end_date && (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('dashboard.noDateHint')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
function Avatar({ username, avatarUrl, size = 32 }) {
|
||||
if (avatarUrl) {
|
||||
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
}
|
||||
const letter = (username || '?')[0].toUpperCase()
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4']
|
||||
const color = colors[letter.charCodeAt(0) % colors.length]
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', background: color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'white', flexShrink: 0,
|
||||
}}>
|
||||
{letter}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [allUsers, setAllUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [removingId, setRemovingId] = useState(null)
|
||||
const toast = useToast()
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && tripId) {
|
||||
loadMembers()
|
||||
loadAllUsers()
|
||||
}
|
||||
}, [isOpen, tripId])
|
||||
|
||||
const loadMembers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await tripsApi.getMembers(tripId)
|
||||
setData(d)
|
||||
} catch {
|
||||
toast.error(t('members.loadError'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAllUsers = async () => {
|
||||
try {
|
||||
const d = await authApi.listUsers()
|
||||
setAllUsers(d.users)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedUserId) return
|
||||
setAdding(true)
|
||||
try {
|
||||
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
|
||||
await tripsApi.addMember(tripId, target.username)
|
||||
setSelectedUserId('')
|
||||
await loadMembers()
|
||||
toast.success(`${target.username} ${t('members.added')}`)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('members.addError'))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (userId, isSelf) => {
|
||||
const msg = isSelf
|
||||
? t('members.confirmLeave')
|
||||
: t('members.confirmRemove')
|
||||
if (!confirm(msg)) return
|
||||
setRemovingId(userId)
|
||||
try {
|
||||
await tripsApi.removeMember(tripId, userId)
|
||||
if (isSelf) { onClose(); window.location.reload() }
|
||||
else { await loadMembers(); toast.success(t('members.removed')) }
|
||||
} catch {
|
||||
toast.error(t('members.removeError'))
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Users not yet in the trip
|
||||
const existingIds = new Set([
|
||||
data?.owner?.id,
|
||||
...(data?.members?.map(m => m.id) || []),
|
||||
])
|
||||
const availableUsers = allUsers.filter(u => !existingIds.has(u.id))
|
||||
|
||||
const isCurrentOwner = data?.owner?.id === user?.id
|
||||
const allMembers = data ? [
|
||||
{ ...data.owner, role: 'owner' },
|
||||
...data.members,
|
||||
] : []
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{tripTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={selectedUserId}
|
||||
onChange={value => setSelectedUserId(value)}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
...availableUsers.map(u => ({
|
||||
value: u.id,
|
||||
label: u.username,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !selectedUserId}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
|
||||
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} style={{ height: 48, background: 'var(--bg-tertiary)', borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{allMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
||||
return (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 12px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-secondary)',
|
||||
}}>
|
||||
<Avatar username={member.username} avatarUrl={member.avatar_url} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{member.username}</span>
|
||||
{isSelf && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>({t('members.you')})</span>}
|
||||
{member.role === 'owner' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<Crown size={9} /> {t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
onClick={() => handleRemove(member.id, isSelf)}
|
||||
disabled={removingId === member.id}
|
||||
title={isSelf ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
{isSelf ? <LogOut size={14} /> : <UserMinus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun,
|
||||
Clouds: Cloud,
|
||||
Rain: CloudRain,
|
||||
Drizzle: CloudDrizzle,
|
||||
Thunderstorm: CloudLightning,
|
||||
Snow: CloudSnow,
|
||||
Mist: Wind,
|
||||
Fog: Wind,
|
||||
Haze: Wind,
|
||||
}
|
||||
|
||||
function WeatherIcon({ main, size = 13 }) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
|
||||
function getWeatherCache(key) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (raw === null) return undefined
|
||||
return JSON.parse(raw)
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
function setWeatherCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const [weather, setWeather] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
|
||||
useEffect(() => {
|
||||
if (!lat || !lng || !date) return
|
||||
const rLat = Math.round(lat * 100) / 100
|
||||
const rLng = Math.round(lng * 100) / 100
|
||||
const cacheKey = `weather_${rLat}_${rLng}_${date}`
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached === null) setFailed(true)
|
||||
else setWeather(cached)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setWeatherCache(cacheKey, null)
|
||||
setFailed(true)
|
||||
} else {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [lat, lng, date])
|
||||
|
||||
if (!lat || !lng) return null
|
||||
|
||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: '#d1d5db', ...fontStyle }}>…</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (failed || !weather) {
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', ...fontStyle }}>—</span>
|
||||
)
|
||||
}
|
||||
|
||||
const rawTemp = weather.temp
|
||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={12} />
|
||||
{temp !== null && <span>{temp}{unit}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={15} />
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
|
||||
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
|
||||
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
|
||||
|
||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } else setViewMonth(m => m + 1) }
|
||||
|
||||
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
|
||||
const days = daysInMonth(viewYear, viewMonth)
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
|
||||
const selectDay = (day) => {
|
||||
const y = String(viewYear)
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
onChange(`${y}-${m}-${d}`)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
...(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (!r) return { top: 0, left: 0 }
|
||||
const w = 268, pad = 8
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
let left = r.left
|
||||
let top = r.bottom + 4
|
||||
// Keep within viewport horizontally
|
||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||
// If not enough space below, open above
|
||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
||||
// On very small screens, center horizontally
|
||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||
return { top, left }
|
||||
})(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Month nav */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<button type="button" onClick={prevMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{monthLabel}</span>
|
||||
<button type="button" onClick={nextMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
|
||||
{weekdays.map((d, i) => (
|
||||
<div key={i} style={{ textAlign: 'center', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '2px 0' }}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{Array.from({ length: startDay }, (_, i) => <div key={`e-${i}`} />)}
|
||||
{Array.from({ length: days }, (_, i) => {
|
||||
const d = i + 1
|
||||
const sel = d === selectedDay
|
||||
const td = isToday(d)
|
||||
return (
|
||||
<button key={d} type="button" onClick={() => selectDay(d)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8, border: 'none',
|
||||
background: sel ? 'var(--accent)' : 'transparent',
|
||||
color: sel ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
fontSize: 12, fontWeight: sel ? 700 : td ? 600 : 400,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
outline: td && !sel ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent' }}>
|
||||
{d}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clear */}
|
||||
{value && (
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
|
||||
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', padding: '3px 8px', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const { locale } = useTranslation()
|
||||
// value = "2024-03-15T14:30" oder ""
|
||||
const [datePart, timePart] = (value || '').split('T')
|
||||
|
||||
const handleDateChange = (d) => {
|
||||
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
||||
}
|
||||
const handleTimeChange = (t) => {
|
||||
const d = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(t ? `${d}T${t}` : `${d}T00:00`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, ...style }}>
|
||||
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
|
||||
<div style={{ width: 110, flexShrink: 0 }}>
|
||||
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline re-export for convenience
|
||||
import CustomTimePicker from './CustomTimePicker'
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [], // [{ value, label, icon? }]
|
||||
placeholder = '',
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md', // 'sm' | 'md'
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const searchRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && searchable && searchRef.current) searchRef.current.focus()
|
||||
}, [open, searchable])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const selected = options.find(o => o.value === value)
|
||||
const filtered = searchable && search
|
||||
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: options
|
||||
|
||||
const sm = size === 'sm'
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
overflow: 'hidden',
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
}}>
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<div style={{ padding: '6px 6px 2px' }}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="..."
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
|
||||
padding: '5px 8px', fontSize: 12, outline: 'none', fontFamily: 'inherit',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '4px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
||||
) : (
|
||||
filtered.map(option => {
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 10px', borderRadius: 6,
|
||||
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit',
|
||||
cursor: 'pointer', textAlign: 'left', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
|
||||
>
|
||||
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
|
||||
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes selectIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function formatDisplay(val, is12h) {
|
||||
if (!val) return ''
|
||||
const [h, m] = val.split(':').map(Number)
|
||||
if (isNaN(h) || isNaN(m)) return val
|
||||
if (!is12h) return val
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const [h, m] = (value || '').split(':').map(Number)
|
||||
const hour = isNaN(h) ? null : h
|
||||
const minute = isNaN(m) ? null : m
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const update = (newH, newM) => {
|
||||
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
|
||||
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
|
||||
onChange(`${hh}:${mm}`)
|
||||
}
|
||||
|
||||
const incHour = () => update(((hour ?? -1) + 1) % 24, minute ?? 0)
|
||||
const decHour = () => update(((hour ?? 1) - 1 + 24) % 24, minute ?? 0)
|
||||
const incMin = () => {
|
||||
const newM = ((minute ?? -5) + 5) % 60
|
||||
const newH = newM < (minute ?? 0) ? ((hour ?? 0) + 1) % 24 : (hour ?? 0)
|
||||
update(newH, newM)
|
||||
}
|
||||
const decMin = () => {
|
||||
const newM = ((minute ?? 5) - 5 + 60) % 60
|
||||
const newH = newM > (minute ?? 0) ? ((hour ?? 0) - 1 + 24) % 24 : (hour ?? 0)
|
||||
update(newH, newM)
|
||||
}
|
||||
|
||||
const btnStyle = {
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
||||
transition: 'color 0.15s',
|
||||
}
|
||||
|
||||
const handleInput = (e) => {
|
||||
const raw = e.target.value
|
||||
onChange(raw)
|
||||
// Auto-format: wenn "1430" → "14:30"
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||
else if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
onChange(hh.padStart(2, '0') + ':' + mm)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value) return
|
||||
const clean = value.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||
const m = Math.min(59, Math.max(0, parseInt(mm)))
|
||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
||||
} else if (/^\d{3,4}$/.test(clean)) {
|
||||
const s = clean.padStart(4, '0')
|
||||
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
|
||||
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<div style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', overflow: 'hidden',
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputFocused ? value : formatDisplay(value, is12h)}
|
||||
onChange={handleInput}
|
||||
onFocus={() => setInputFocused(true)}
|
||||
onBlur={() => { setInputFocused(false); handleBlur() }}
|
||||
placeholder={is12h ? '2:30 PM' : placeholder}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'transparent',
|
||||
padding: '8px 10px 8px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||
color: value ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px',
|
||||
display: 'flex', alignItems: 'center', color: 'var(--text-faint)',
|
||||
transition: 'color 0.15s', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
padding: 12, display: 'flex', alignItems: 'center', gap: 6,
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Stunden */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<button type="button" onClick={incHour} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{hour !== null ? (is12h ? String(hour === 0 ? 12 : hour > 12 ? hour - 12 : hour) : String(hour).padStart(2, '0')) : '--'}
|
||||
</div>
|
||||
<button type="button" onClick={decHour} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
|
||||
|
||||
{/* Minuten */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<button type="button" onClick={incMin} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{minute !== null ? String(minute).padStart(2, '0') : '--'}
|
||||
</div>
|
||||
<button type="button" onClick={decMin} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AM/PM Toggle */}
|
||||
{is12h && hour !== null && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginLeft: 4 }}>
|
||||
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div style={{
|
||||
width: 36, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 8,
|
||||
}}>
|
||||
{hour >= 12 ? 'PM' : 'AM'}
|
||||
</div>
|
||||
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear */}
|
||||
{value && (
|
||||
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
|
||||
style={{ ...btnStyle, marginLeft: 4, fontSize: 11, color: 'var(--text-faint)', padding: '4px 6px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
'2xl': 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
}) {
|
||||
const handleEsc = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
const mouseDownTarget = useRef(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[90vh]
|
||||
animate-in fade-in zoom-in-95 duration-200
|
||||
`}
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
|
||||
const googlePhotoCache = new Map()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!place.google_place_id) return
|
||||
|
||||
if (googlePhotoCache.has(place.google_place_id)) {
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
|
||||
return
|
||||
}
|
||||
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
googlePhotoCache.set(place.google_place_id, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [place.id, place.image_url, place.google_place_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
const iconSize = Math.round(size * 0.46)
|
||||
|
||||
const containerStyle = {
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: bgColor,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}
|
||||
|
||||
if (photoSrc) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
const ToastContext = createContext(null)
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 300)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
// Make addToast globally accessible
|
||||
useEffect(() => {
|
||||
window.__addToast = addToast
|
||||
return () => { delete window.__addToast }
|
||||
}, [addToast])
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
|
||||
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
|
||||
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
|
||||
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
|
||||
}
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-white border-l-4 border-emerald-500',
|
||||
error: 'bg-white border-l-4 border-red-500',
|
||||
warning: 'bg-white border-l-4 border-amber-500',
|
||||
info: 'bg-white border-l-4 border-blue-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
${bgColors[toast.type] || bgColors.info}
|
||||
${toast.removing ? 'toast-exit' : 'toast-enter'}
|
||||
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
|
||||
min-w-0
|
||||
`}
|
||||
>
|
||||
{icons[toast.type] || icons.info}
|
||||
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const show = useCallback((message, type, duration) => {
|
||||
if (window.__addToast) {
|
||||
window.__addToast(message, type, duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
success: (message, duration) => show(message, 'success', duration),
|
||||
error: (message, duration) => show(message, 'error', duration),
|
||||
warning: (message, duration) => show(message, 'warning', duration),
|
||||
info: (message, duration) => show(message, 'info', duration),
|
||||
}
|
||||
}
|
||||
|
||||
export default useToast
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
|
||||
Bus, Train, Car, Plane, Ship, Bike,
|
||||
Activity, Dumbbell, Mountain, Tent, Anchor,
|
||||
Coffee, Beer, Wine, Utensils,
|
||||
Camera, Music, Theater, Ticket,
|
||||
TreePine, Waves, Leaf, Flower2, Sun,
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const CATEGORY_ICON_MAP = {
|
||||
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
|
||||
Bus, Train, Car, Plane, Ship, Bike,
|
||||
Activity, Dumbbell, Mountain, Tent, Anchor,
|
||||
Coffee, Beer, Wine, Utensils,
|
||||
Camera, Music, Theater, Ticket,
|
||||
TreePine, Waves, Leaf, Flower2, Sun,
|
||||
Globe, Compass, Flag, Navigation, Map,
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
}
|
||||
|
||||
export const ICON_LABELS = {
|
||||
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
||||
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
|
||||
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
|
||||
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
|
||||
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
|
||||
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
|
||||
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
|
||||
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
|
||||
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
|
||||
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
||||
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
|
||||
}
|
||||
|
||||
export function getCategoryIcon(iconName) {
|
||||
return CATEGORY_ICON_MAP[iconName] || MapPin
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
|
||||
const translations = { de, en }
|
||||
const TranslationContext = createContext({ t: (k) => k, language: 'de', locale: 'de-DE' })
|
||||
|
||||
export function TranslationProvider({ children }) {
|
||||
const language = useSettingsStore(s => s.settings.language) || 'de'
|
||||
|
||||
const value = useMemo(() => {
|
||||
const strings = translations[language] || translations.de
|
||||
const fallback = translations.de
|
||||
|
||||
function t(key, params) {
|
||||
let val = strings[key] ?? fallback[key] ?? key
|
||||
// Arrays/Objects direkt zurückgeben (z.B. Vorschläge-Liste)
|
||||
if (typeof val !== 'string') return val
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), v)
|
||||
})
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
|
||||
}, [language])
|
||||
|
||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
return useContext(TranslationContext)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TranslationProvider, useTranslation } from './TranslationContext'
|
||||
@@ -0,0 +1,589 @@
|
||||
const de = {
|
||||
// Allgemein
|
||||
'common.save': 'Speichern',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Fehler',
|
||||
'common.back': 'Zurück',
|
||||
'common.all': 'Alle',
|
||||
'common.close': 'Schließen',
|
||||
'common.open': 'Öffnen',
|
||||
'common.upload': 'Hochladen',
|
||||
'common.search': 'Suchen',
|
||||
'common.confirm': 'Bestätigen',
|
||||
'common.ok': 'OK',
|
||||
'common.yes': 'Ja',
|
||||
'common.no': 'Nein',
|
||||
'common.or': 'oder',
|
||||
'common.none': 'Keine',
|
||||
'common.date': 'Datum',
|
||||
'common.rename': 'Umbenennen',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'E-Mail',
|
||||
'common.password': 'Passwort',
|
||||
'common.saving': 'Speichern...',
|
||||
'common.update': 'Aktualisieren',
|
||||
'common.change': 'Ändern',
|
||||
'common.uploading': 'Hochladen…',
|
||||
|
||||
// Navbar
|
||||
'nav.trip': 'Reise',
|
||||
'nav.share': 'Teilen',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.logout': 'Abmelden',
|
||||
'nav.lightMode': 'Heller Modus',
|
||||
'nav.darkMode': 'Dunkler Modus',
|
||||
'nav.administrator': 'Administrator',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Meine Reisen',
|
||||
'dashboard.subtitle.loading': 'Reisen werden geladen...',
|
||||
'dashboard.subtitle.trips': '{count} Reisen ({archived} archiviert)',
|
||||
'dashboard.subtitle.empty': 'Starte deine erste Reise',
|
||||
'dashboard.subtitle.activeOne': '{count} aktive Reise',
|
||||
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
||||
'dashboard.newTrip': 'Neue Reise',
|
||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||
'dashboard.nextTrip': 'Nächste Reise',
|
||||
'dashboard.shared': 'Geteilt',
|
||||
'dashboard.sharedBy': 'Geteilt von {name}',
|
||||
'dashboard.days': 'Tage',
|
||||
'dashboard.places': 'Orte',
|
||||
'dashboard.archive': 'Archivieren',
|
||||
'dashboard.restore': 'Wiederherstellen',
|
||||
'dashboard.archived': 'Archiviert',
|
||||
'dashboard.status.ongoing': 'Laufend',
|
||||
'dashboard.status.today': 'Heute',
|
||||
'dashboard.status.tomorrow': 'Morgen',
|
||||
'dashboard.status.past': 'Vergangen',
|
||||
'dashboard.status.daysLeft': 'Noch {count} Tage',
|
||||
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
|
||||
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
|
||||
'dashboard.toast.createError': 'Fehler beim Erstellen',
|
||||
'dashboard.toast.updated': 'Reise aktualisiert!',
|
||||
'dashboard.toast.updateError': 'Fehler beim Aktualisieren',
|
||||
'dashboard.toast.deleted': 'Reise gelöscht',
|
||||
'dashboard.toast.deleteError': 'Fehler beim Löschen',
|
||||
'dashboard.toast.archived': 'Reise archiviert',
|
||||
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
|
||||
'dashboard.toast.restored': 'Reise wiederhergestellt',
|
||||
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
|
||||
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
|
||||
'dashboard.editTrip': 'Reise bearbeiten',
|
||||
'dashboard.createTrip': 'Neue Reise erstellen',
|
||||
'dashboard.tripTitle': 'Titel',
|
||||
'dashboard.tripTitlePlaceholder': 'z.B. Sommer in Japan',
|
||||
'dashboard.tripDescription': 'Beschreibung',
|
||||
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
|
||||
'dashboard.startDate': 'Startdatum',
|
||||
'dashboard.endDate': 'Enddatum',
|
||||
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
|
||||
'dashboard.coverImage': 'Titelbild',
|
||||
'dashboard.addCoverImage': 'Titelbild hinzufügen',
|
||||
'dashboard.coverSaved': 'Titelbild gespeichert',
|
||||
'dashboard.coverUploadError': 'Fehler beim Hochladen',
|
||||
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
|
||||
'dashboard.titleRequired': 'Titel ist erforderlich',
|
||||
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||
'settings.map': 'Karte',
|
||||
'settings.mapTemplate': 'Karten-Vorlage',
|
||||
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
|
||||
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
||||
'settings.latitude': 'Breitengrad',
|
||||
'settings.longitude': 'Längengrad',
|
||||
'settings.saveMap': 'Karte speichern',
|
||||
'settings.apiKeys': 'API-Schlüssel',
|
||||
'settings.mapsKey': 'Google Maps API-Schlüssel',
|
||||
'settings.mapsKeyHint': 'Für Ortsuche. Benötigt Places API (New). Erhalten unter console.cloud.google.com',
|
||||
'settings.weatherKey': 'OpenWeatherMap API-Schlüssel',
|
||||
'settings.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org/api',
|
||||
'settings.keyPlaceholder': 'Schlüssel eingeben...',
|
||||
'settings.configured': 'Konfiguriert',
|
||||
'settings.saveKeys': 'Schlüssel speichern',
|
||||
'settings.display': 'Darstellung',
|
||||
'settings.colorMode': 'Farbmodus',
|
||||
'settings.light': 'Hell',
|
||||
'settings.dark': 'Dunkel',
|
||||
'settings.language': 'Sprache',
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.account': 'Konto',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
'settings.role': 'Rolle',
|
||||
'settings.roleAdmin': 'Administrator',
|
||||
'settings.roleUser': 'Benutzer',
|
||||
'settings.saveProfile': 'Profil speichern',
|
||||
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
|
||||
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
|
||||
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
|
||||
'settings.toast.profileSaved': 'Profil aktualisiert',
|
||||
'settings.uploadAvatar': 'Profilbild hochladen',
|
||||
'settings.removeAvatar': 'Profilbild entfernen',
|
||||
'settings.avatarUploaded': 'Profilbild aktualisiert',
|
||||
'settings.avatarRemoved': 'Profilbild entfernt',
|
||||
'settings.avatarError': 'Fehler beim Hochladen',
|
||||
|
||||
// Login
|
||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||
'login.tagline': 'Deine Reisen.\nDein Plan.',
|
||||
'login.description': 'Plane Reisen gemeinsam mit interaktiven Karten, Budgets und Echtzeit-Sync.',
|
||||
'login.features.maps': 'Interaktive Karten',
|
||||
'login.features.mapsDesc': 'Google Places, Routen & Clustering',
|
||||
'login.features.realtime': 'Echtzeit-Sync',
|
||||
'login.features.realtimeDesc': 'Gemeinsam planen via WebSocket',
|
||||
'login.features.budget': 'Budget-Tracking',
|
||||
'login.features.budgetDesc': 'Kategorien, Diagramme & Pro-Kopf',
|
||||
'login.features.collab': 'Zusammenarbeit',
|
||||
'login.features.collabDesc': 'Multi-User mit geteilten Reisen',
|
||||
'login.features.packing': 'Packlisten',
|
||||
'login.features.packingDesc': 'Kategorien & Fortschritt',
|
||||
'login.features.bookings': 'Buchungen',
|
||||
'login.features.bookingsDesc': 'Flüge, Hotels, Restaurants & mehr',
|
||||
'login.features.files': 'Dokumente',
|
||||
'login.features.filesDesc': 'Dateien hochladen & verwalten',
|
||||
'login.features.routes': 'Routenoptimierung',
|
||||
'login.features.routesDesc': 'Auto-Optimierung & Google Maps Export',
|
||||
'login.selfHosted': 'Self-hosted \u00B7 Open Source \u00B7 Deine Daten bleiben bei dir',
|
||||
'login.title': 'Anmelden',
|
||||
'login.subtitle': 'Willkommen zurück',
|
||||
'login.signingIn': 'Anmelden…',
|
||||
'login.signIn': 'Anmelden',
|
||||
'login.createAdmin': 'Admin-Konto erstellen',
|
||||
'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.',
|
||||
'login.createAccount': 'Konto erstellen',
|
||||
'login.createAccountHint': 'Neues Konto registrieren.',
|
||||
'login.creating': 'Erstelle…',
|
||||
'login.noAccount': 'Noch kein Konto?',
|
||||
'login.hasAccount': 'Bereits ein Konto?',
|
||||
'login.register': 'Registrieren',
|
||||
'login.emailPlaceholder': 'deine@email.de',
|
||||
'login.username': 'Benutzername',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Administration',
|
||||
'admin.subtitle': 'Benutzerverwaltung und Systemeinstellungen',
|
||||
'admin.tabs.users': 'Benutzer',
|
||||
'admin.tabs.categories': 'Kategorien',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.stats.users': 'Benutzer',
|
||||
'admin.stats.trips': 'Reisen',
|
||||
'admin.stats.places': 'Orte',
|
||||
'admin.stats.photos': 'Fotos',
|
||||
'admin.stats.files': 'Dateien',
|
||||
'admin.table.user': 'Benutzer',
|
||||
'admin.table.email': 'E-Mail',
|
||||
'admin.table.role': 'Rolle',
|
||||
'admin.table.created': 'Erstellt',
|
||||
'admin.table.actions': 'Aktionen',
|
||||
'admin.you': '(Du)',
|
||||
'admin.editUser': 'Benutzer bearbeiten',
|
||||
'admin.newPassword': 'Neues Passwort',
|
||||
'admin.newPasswordHint': 'Leer lassen, um das Passwort nicht zu ändern',
|
||||
'admin.deleteUser': 'Benutzer "{name}" löschen? Alle Reisen werden unwiderruflich gelöscht.',
|
||||
'admin.deleteUserTitle': 'Benutzer löschen',
|
||||
'admin.newPasswordPlaceholder': 'Neues Passwort eingeben…',
|
||||
'admin.toast.loadError': 'Fehler beim Laden der Admin-Daten',
|
||||
'admin.toast.userUpdated': 'Benutzer aktualisiert',
|
||||
'admin.toast.updateError': 'Fehler beim Aktualisieren',
|
||||
'admin.toast.userDeleted': 'Benutzer gelöscht',
|
||||
'admin.toast.deleteError': 'Fehler beim Löschen',
|
||||
'admin.toast.cannotDeleteSelf': 'Eigenes Konto kann nicht gelöscht werden',
|
||||
'admin.toast.userCreated': 'Benutzer erstellt',
|
||||
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
|
||||
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
|
||||
'admin.createUser': 'Benutzer anlegen',
|
||||
'admin.tabs.settings': 'Einstellungen',
|
||||
'admin.allowRegistration': 'Registrierung erlauben',
|
||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||
'admin.apiKeys': 'API-Schlüssel',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
|
||||
'admin.weatherKey': 'OpenWeatherMap API Key',
|
||||
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
|
||||
'admin.validateKey': 'Test',
|
||||
'admin.keyValid': 'Verbunden',
|
||||
'admin.keyInvalid': 'Ungültig',
|
||||
'admin.keySaved': 'API-Schlüssel gespeichert',
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Planung',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.packing': 'Packliste',
|
||||
'trip.tabs.packingShort': 'Packliste',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Dateien',
|
||||
'trip.loading': 'Reise wird geladen...',
|
||||
'trip.mobilePlan': 'Planung',
|
||||
'trip.mobilePlaces': 'Orte',
|
||||
'trip.toast.placeUpdated': 'Ort aktualisiert',
|
||||
'trip.toast.placeAdded': 'Ort hinzugefügt',
|
||||
'trip.toast.placeDeleted': 'Ort gelöscht',
|
||||
'trip.toast.selectDay': 'Bitte wähle zuerst einen Tag aus',
|
||||
'trip.toast.assignedToDay': 'Ort wurde dem Tag zugewiesen',
|
||||
'trip.toast.reorderError': 'Fehler beim Sortieren',
|
||||
'trip.toast.reservationUpdated': 'Reservierung aktualisiert',
|
||||
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
|
||||
'trip.toast.deleted': 'Gelöscht',
|
||||
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.transport.car': 'Auto',
|
||||
'dayplan.transport.walk': 'Zu Fuß',
|
||||
'dayplan.transport.bike': 'Fahrrad',
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
'dayplan.addNote': 'Notiz hinzufügen',
|
||||
'dayplan.editNote': 'Notiz bearbeiten',
|
||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||
'dayplan.noteEdit': 'Notiz bearbeiten',
|
||||
'dayplan.noteTitle': 'Notiz',
|
||||
'dayplan.noteSubtitle': 'Tagesnotiz',
|
||||
'dayplan.totalCost': 'Gesamtkosten',
|
||||
'dayplan.days': 'Tage',
|
||||
'dayplan.dayN': 'Tag {n}',
|
||||
'dayplan.calculating': 'Berechne...',
|
||||
'dayplan.route': 'Route',
|
||||
'dayplan.optimize': 'Optimieren',
|
||||
'dayplan.optimized': 'Route optimiert',
|
||||
'dayplan.routeError': 'Fehler bei der Routenberechnung',
|
||||
'dayplan.confirmed': 'Bestätigt',
|
||||
'dayplan.pendingRes': 'Ausstehend',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
|
||||
'dayplan.pdfError': 'Fehler beim PDF-Export',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort hinzufügen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.count': '{count} Orte',
|
||||
'places.countSingular': '1 Ort',
|
||||
'places.allPlanned': 'Alle Orte sind eingeplant',
|
||||
'places.noneFound': 'Keine Orte gefunden',
|
||||
'places.editPlace': 'Ort bearbeiten',
|
||||
'places.formName': 'Name',
|
||||
'places.formNamePlaceholder': 'z.B. Eiffelturm',
|
||||
'places.formDescription': 'Beschreibung',
|
||||
'places.formDescriptionPlaceholder': 'Kurze Beschreibung...',
|
||||
'places.formAddress': 'Adresse',
|
||||
'places.formAddressPlaceholder': 'Straße, Stadt, Land',
|
||||
'places.formLat': 'Breitengrad (z.B. 48.8566)',
|
||||
'places.formLng': 'Längengrad (z.B. 2.3522)',
|
||||
'places.formCategory': 'Kategorie',
|
||||
'places.noCategory': 'Keine Kategorie',
|
||||
'places.categoryNamePlaceholder': 'Kategoriename',
|
||||
'places.formTime': 'Uhrzeit',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||
'places.formReservation': 'Reservierung',
|
||||
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
||||
'places.mapsSearchPlaceholder': 'Google Maps suchen...',
|
||||
'places.mapsSearchError': 'Google Maps Suche fehlgeschlagen. Bitte API-Schlüssel in den Einstellungen hinterlegen.',
|
||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||
'places.nameRequired': 'Bitte einen Namen eingeben',
|
||||
'places.saveError': 'Fehler beim Speichern',
|
||||
'places.transport.walking': '🚶 Zu Fuß',
|
||||
'places.transport.driving': '🚗 Auto',
|
||||
'places.transport.cycling': '🚲 Fahrrad',
|
||||
'places.transport.transit': '🚌 ÖPNV',
|
||||
|
||||
// Place Inspector
|
||||
'inspector.opened': 'Geöffnet',
|
||||
'inspector.closed': 'Geschlossen',
|
||||
'inspector.openingHours': 'Öffnungszeiten',
|
||||
'inspector.showHours': 'Öffnungszeiten anzeigen',
|
||||
'inspector.files': 'Dateien',
|
||||
'inspector.filesCount': '{count} Dateien',
|
||||
'inspector.removeFromDay': 'Vom Tag entfernen',
|
||||
'inspector.addToDay': 'Zum Tag hinzufügen',
|
||||
'inspector.confirmedRes': 'Bestätigte Reservierung',
|
||||
'inspector.pendingRes': 'Ausstehende Reservierung',
|
||||
'inspector.google': 'In Google Maps öffnen',
|
||||
'inspector.website': 'Webseite öffnen',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Buchungen',
|
||||
'reservations.empty': 'Keine Reservierungen vorhanden',
|
||||
'reservations.emptyHint': 'Füge Reservierungen für Flüge, Hotels und mehr hinzu',
|
||||
'reservations.add': 'Reservierung hinzufügen',
|
||||
'reservations.addManual': 'Manuelle Buchung',
|
||||
'reservations.placeHint': 'Tipp: Buchungen werden am besten direkt über einen angelegten Ort erstellt, um sie mit dem Tagesplan zu verknüpfen.',
|
||||
'reservations.confirmed': 'Bestätigt',
|
||||
'reservations.pending': 'Ausstehend',
|
||||
'reservations.summary': '{confirmed} bestätigt, {pending} ausstehend',
|
||||
'reservations.fromPlan': 'Aus Planung',
|
||||
'reservations.showFiles': 'Dateien anzeigen',
|
||||
'reservations.editTitle': 'Reservierung bearbeiten',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.datetime': 'Datum & Uhrzeit',
|
||||
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
||||
'reservations.notes': 'Notizen',
|
||||
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
|
||||
'reservations.type.flight': 'Flug',
|
||||
'reservations.type.hotel': 'Hotel',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Zug',
|
||||
'reservations.type.car': 'Mietwagen',
|
||||
'reservations.type.cruise': 'Kreuzfahrt',
|
||||
'reservations.type.event': 'Veranstaltung',
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Sonstiges',
|
||||
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
|
||||
'reservations.toast.updated': 'Reservierung aktualisiert',
|
||||
'reservations.toast.removed': 'Reservierung gelöscht',
|
||||
'reservations.toast.saveError': 'Fehler beim Speichern',
|
||||
'reservations.toast.updateError': 'Fehler beim Aktualisieren',
|
||||
'reservations.toast.deleteError': 'Fehler beim Löschen',
|
||||
'reservations.confirm.remove': 'Reservierung für "{name}" entfernen?',
|
||||
'reservations.toast.fileUploaded': 'Datei hochgeladen',
|
||||
'reservations.toast.uploadError': 'Fehler beim Hochladen',
|
||||
'reservations.newTitle': 'Neue Buchung',
|
||||
'reservations.bookingType': 'Art der Buchung',
|
||||
'reservations.titleLabel': 'Titel',
|
||||
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
|
||||
'reservations.locationAddress': 'Ort / Adresse',
|
||||
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
|
||||
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
|
||||
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
|
||||
'reservations.day': 'Tag',
|
||||
'reservations.noDay': 'Kein Tag',
|
||||
'reservations.place': 'Ort',
|
||||
'reservations.noPlace': 'Kein Ort',
|
||||
'reservations.pendingSave': 'wird gespeichert…',
|
||||
'reservations.uploading': 'Wird hochgeladen...',
|
||||
'reservations.attachFile': 'Datei anhängen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.emptyTitle': 'Noch kein Budget erstellt',
|
||||
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
|
||||
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
|
||||
'budget.createCategory': 'Kategorie erstellen',
|
||||
'budget.category': 'Kategorie',
|
||||
'budget.categoryName': 'Kategoriename',
|
||||
'budget.table.name': 'Name',
|
||||
'budget.table.total': 'Gesamt',
|
||||
'budget.table.persons': 'Personen',
|
||||
'budget.table.days': 'Tage',
|
||||
'budget.table.perPerson': 'Pro Person',
|
||||
'budget.table.perDay': 'Pro Tag',
|
||||
'budget.table.perPersonDay': 'Pro Person/Tag',
|
||||
'budget.table.note': 'Notiz',
|
||||
'budget.newEntry': 'Neuer Eintrag',
|
||||
'budget.defaultEntry': 'Neuer Eintrag',
|
||||
'budget.defaultCategory': 'Neue Kategorie',
|
||||
'budget.total': 'Gesamt',
|
||||
'budget.totalBudget': 'Gesamtbudget',
|
||||
'budget.byCategory': 'Nach Kategorie',
|
||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||
'budget.deleteCategory': 'Kategorie löschen',
|
||||
|
||||
// Files
|
||||
'files.title': 'Dateien',
|
||||
'files.count': '{count} Dateien',
|
||||
'files.countSingular': '1 Datei',
|
||||
'files.uploaded': '{count} hochgeladen',
|
||||
'files.uploadError': 'Fehler beim Hochladen',
|
||||
'files.dropzone': 'Dateien hier ablegen',
|
||||
'files.dropzoneHint': 'oder klicken zum Auswählen',
|
||||
'files.uploading': 'Wird hochgeladen...',
|
||||
'files.filterAll': 'Alle',
|
||||
'files.filterPdf': 'PDFs',
|
||||
'files.filterImages': 'Bilder',
|
||||
'files.filterDocs': 'Dokumente',
|
||||
'files.empty': 'Keine Dateien vorhanden',
|
||||
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
|
||||
'files.openTab': 'In neuem Tab öffnen',
|
||||
'files.confirm.delete': 'Möchtest du diese Datei wirklich löschen?',
|
||||
'files.toast.deleted': 'Datei gelöscht',
|
||||
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
||||
'files.sourcePlan': 'Tagesplan',
|
||||
'files.sourceBooking': 'Buchung',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Packliste',
|
||||
'packing.empty': 'Packliste ist leer',
|
||||
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
||||
'packing.clearChecked': '{count} abgehakte entfernen',
|
||||
'packing.clearCheckedShort': '{count} entfernen',
|
||||
'packing.suggestions': 'Vorschläge',
|
||||
'packing.suggestionsTitle': 'Vorschläge hinzufügen',
|
||||
'packing.allSuggested': 'Alle Vorschläge hinzugefügt',
|
||||
'packing.allPacked': 'Alles gepackt!',
|
||||
'packing.addPlaceholder': 'Neuen Gegenstand hinzufügen...',
|
||||
'packing.categoryPlaceholder': 'Kategorie...',
|
||||
'packing.filterAll': 'Alle',
|
||||
'packing.filterOpen': 'Offen',
|
||||
'packing.filterDone': 'Erledigt',
|
||||
'packing.emptyTitle': 'Packliste ist leer',
|
||||
'packing.emptyHint': 'Füge Gegenstände hinzu oder nutze die Vorschläge',
|
||||
'packing.emptyFiltered': 'Keine Gegenstände in diesem Filter',
|
||||
'packing.menuRename': 'Umbenennen',
|
||||
'packing.menuCheckAll': 'Alle abhaken',
|
||||
'packing.menuUncheckAll': 'Alle Haken entfernen',
|
||||
'packing.menuDeleteCat': 'Kategorie löschen',
|
||||
'packing.changeCategory': 'Kategorie ändern',
|
||||
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
|
||||
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
|
||||
'packing.defaultCategory': 'Sonstiges',
|
||||
'packing.toast.saveError': 'Fehler beim Speichern',
|
||||
'packing.toast.deleteError': 'Fehler beim Löschen',
|
||||
'packing.toast.renameError': 'Fehler beim Umbenennen',
|
||||
'packing.toast.addError': 'Fehler beim Hinzufügen',
|
||||
|
||||
// Packing suggestions
|
||||
'packing.suggestions.items': [
|
||||
{ name: 'Reisepass', category: 'Dokumente' },
|
||||
{ name: 'Personalausweis', category: 'Dokumente' },
|
||||
{ name: 'Reiseversicherung', category: 'Dokumente' },
|
||||
{ name: 'Flugtickets', category: 'Dokumente' },
|
||||
{ name: 'Kreditkarte', category: 'Finanzen' },
|
||||
{ name: 'Bargeld', category: 'Finanzen' },
|
||||
{ name: 'Visum', category: 'Dokumente' },
|
||||
{ name: 'T-Shirts', category: 'Kleidung' },
|
||||
{ name: 'Hosen', category: 'Kleidung' },
|
||||
{ name: 'Unterwäsche', category: 'Kleidung' },
|
||||
{ name: 'Socken', category: 'Kleidung' },
|
||||
{ name: 'Jacke', category: 'Kleidung' },
|
||||
{ name: 'Schlafkleidung', category: 'Kleidung' },
|
||||
{ name: 'Badekleidung', category: 'Kleidung' },
|
||||
{ name: 'Regenjacke', category: 'Kleidung' },
|
||||
{ name: 'Bequeme Schuhe', category: 'Kleidung' },
|
||||
{ name: 'Zahnbürste', category: 'Hygiene' },
|
||||
{ name: 'Zahnpasta', category: 'Hygiene' },
|
||||
{ name: 'Shampoo', category: 'Hygiene' },
|
||||
{ name: 'Deo', category: 'Hygiene' },
|
||||
{ name: 'Sonnencreme', category: 'Hygiene' },
|
||||
{ name: 'Rasierer', category: 'Hygiene' },
|
||||
{ name: 'Ladegerät', category: 'Elektronik' },
|
||||
{ name: 'Powerbank', category: 'Elektronik' },
|
||||
{ name: 'Kopfhörer', category: 'Elektronik' },
|
||||
{ name: 'Reiseadapter', category: 'Elektronik' },
|
||||
{ name: 'Kamera', category: 'Elektronik' },
|
||||
{ name: 'Schmerzmittel', category: 'Gesundheit' },
|
||||
{ name: 'Pflaster', category: 'Gesundheit' },
|
||||
{ name: 'Desinfektionsmittel', category: 'Gesundheit' },
|
||||
],
|
||||
|
||||
// Members / Sharing
|
||||
'members.shareTrip': 'Reise teilen',
|
||||
'members.inviteUser': 'Benutzer einladen',
|
||||
'members.selectUser': 'Benutzer auswählen…',
|
||||
'members.invite': 'Einladen',
|
||||
'members.allHaveAccess': 'Alle Benutzer haben bereits Zugriff.',
|
||||
'members.access': 'Zugriff',
|
||||
'members.person': 'Person',
|
||||
'members.persons': 'Personen',
|
||||
'members.you': 'du',
|
||||
'members.owner': 'Eigentümer',
|
||||
'members.leaveTrip': 'Reise verlassen',
|
||||
'members.removeAccess': 'Zugriff entfernen',
|
||||
'members.confirmLeave': 'Reise verlassen? Du verlierst den Zugriff.',
|
||||
'members.confirmRemove': 'Zugriff für diesen Benutzer entfernen?',
|
||||
'members.loadError': 'Fehler beim Laden der Mitglieder',
|
||||
'members.added': 'hinzugefügt',
|
||||
'members.addError': 'Fehler beim Hinzufügen',
|
||||
'members.removed': 'Mitglied entfernt',
|
||||
'members.removeError': 'Fehler beim Entfernen',
|
||||
|
||||
// Categories (Admin)
|
||||
'categories.title': 'Kategorien',
|
||||
'categories.subtitle': 'Kategorien für Orte verwalten',
|
||||
'categories.new': 'Neue Kategorie',
|
||||
'categories.empty': 'Keine Kategorien vorhanden',
|
||||
'categories.namePlaceholder': 'Kategoriename',
|
||||
'categories.icon': 'Symbol',
|
||||
'categories.color': 'Farbe',
|
||||
'categories.customColor': 'Eigene Farbe wählen',
|
||||
'categories.preview': 'Vorschau',
|
||||
'categories.defaultName': 'Kategorie',
|
||||
'categories.update': 'Aktualisieren',
|
||||
'categories.create': 'Erstellen',
|
||||
'categories.confirm.delete': 'Kategorie löschen? Orte dieser Kategorie werden nicht gelöscht.',
|
||||
'categories.toast.loadError': 'Fehler beim Laden der Kategorien',
|
||||
'categories.toast.nameRequired': 'Bitte einen Namen eingeben',
|
||||
'categories.toast.updated': 'Kategorie aktualisiert',
|
||||
'categories.toast.created': 'Kategorie erstellt',
|
||||
'categories.toast.saveError': 'Fehler beim Speichern',
|
||||
'categories.toast.deleted': 'Kategorie gelöscht',
|
||||
'categories.toast.deleteError': 'Fehler beim Löschen',
|
||||
|
||||
// Backup (Admin)
|
||||
'backup.title': 'Datensicherung',
|
||||
'backup.subtitle': 'Datenbank und alle hochgeladenen Dateien',
|
||||
'backup.refresh': 'Aktualisieren',
|
||||
'backup.upload': 'Backup hochladen',
|
||||
'backup.uploading': 'Wird hochgeladen…',
|
||||
'backup.create': 'Backup erstellen',
|
||||
'backup.creating': 'Erstelle…',
|
||||
'backup.empty': 'Noch keine Backups vorhanden',
|
||||
'backup.createFirst': 'Erstes Backup erstellen',
|
||||
'backup.download': 'Herunterladen',
|
||||
'backup.restore': 'Wiederherstellen',
|
||||
'backup.confirm.restore': 'Backup "{name}" wiederherstellen?\n\nAlle aktuellen Daten werden durch den Backup-Stand ersetzt.',
|
||||
'backup.confirm.uploadRestore': 'Backup-Datei "{name}" hochladen und wiederherstellen?\n\nAlle aktuellen Daten werden überschrieben.',
|
||||
'backup.confirm.delete': 'Backup "{name}" löschen?',
|
||||
'backup.toast.loadError': 'Fehler beim Laden der Backups',
|
||||
'backup.toast.created': 'Backup erfolgreich erstellt',
|
||||
'backup.toast.createError': 'Fehler beim Erstellen des Backups',
|
||||
'backup.toast.restored': 'Backup wiederhergestellt. Seite wird neu geladen…',
|
||||
'backup.toast.restoreError': 'Fehler beim Wiederherstellen',
|
||||
'backup.toast.uploadError': 'Fehler beim Hochladen',
|
||||
'backup.toast.deleted': 'Backup gelöscht',
|
||||
'backup.toast.deleteError': 'Fehler beim Löschen',
|
||||
'backup.toast.downloadError': 'Download fehlgeschlagen',
|
||||
'backup.toast.settingsSaved': 'Auto-Backup Einstellungen gespeichert',
|
||||
'backup.toast.settingsError': 'Fehler beim Speichern der Einstellungen',
|
||||
'backup.auto.title': 'Auto-Backup',
|
||||
'backup.auto.subtitle': 'Automatische Sicherung nach Zeitplan',
|
||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||
'backup.auto.interval': 'Intervall',
|
||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||
'backup.interval.hourly': 'Stündlich',
|
||||
'backup.interval.daily': 'Täglich',
|
||||
'backup.interval.weekly': 'Wöchentlich',
|
||||
'backup.interval.monthly': 'Monatlich',
|
||||
'backup.keep.1day': '1 Tag',
|
||||
'backup.keep.3days': '3 Tage',
|
||||
'backup.keep.7days': '7 Tage',
|
||||
'backup.keep.14days': '14 Tage',
|
||||
'backup.keep.30days': '30 Tage',
|
||||
'backup.keep.forever': 'Immer behalten',
|
||||
|
||||
// PDF
|
||||
'pdf.travelPlan': 'Reiseplan',
|
||||
'pdf.planned': 'Eingeplant',
|
||||
'pdf.costLabel': 'Kosten EUR',
|
||||
'pdf.preview': 'PDF Vorschau',
|
||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||
|
||||
// Dashboard Stats
|
||||
'stats.countries': 'Länder',
|
||||
'stats.cities': 'Städte',
|
||||
'stats.trips': 'Reisen',
|
||||
'stats.places': 'Orte',
|
||||
'stats.worldProgress': 'Weltfortschritt',
|
||||
'stats.visited': 'besucht',
|
||||
'stats.remaining': 'verbleibend',
|
||||
'stats.visitedCountries': 'Besuchte Länder',
|
||||
}
|
||||
|
||||
export default de
|
||||
@@ -0,0 +1,589 @@
|
||||
const en = {
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.back': 'Back',
|
||||
'common.all': 'All',
|
||||
'common.close': 'Close',
|
||||
'common.open': 'Open',
|
||||
'common.upload': 'Upload',
|
||||
'common.search': 'Search',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.ok': 'OK',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.or': 'or',
|
||||
'common.none': 'None',
|
||||
'common.date': 'Date',
|
||||
'common.rename': 'Rename',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
'common.saving': 'Saving...',
|
||||
'common.update': 'Update',
|
||||
'common.change': 'Change',
|
||||
'common.uploading': 'Uploading…',
|
||||
|
||||
// Navbar
|
||||
'nav.trip': 'Trip',
|
||||
'nav.share': 'Share',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.logout': 'Log out',
|
||||
'nav.lightMode': 'Light Mode',
|
||||
'nav.darkMode': 'Dark Mode',
|
||||
'nav.administrator': 'Administrator',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'My Trips',
|
||||
'dashboard.subtitle.loading': 'Loading trips...',
|
||||
'dashboard.subtitle.trips': '{count} trips ({archived} archived)',
|
||||
'dashboard.subtitle.empty': 'Start your first trip',
|
||||
'dashboard.subtitle.activeOne': '{count} active trip',
|
||||
'dashboard.subtitle.activeMany': '{count} active trips',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
||||
'dashboard.newTrip': 'New Trip',
|
||||
'dashboard.emptyTitle': 'No trips yet',
|
||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||
'dashboard.emptyButton': 'Create First Trip',
|
||||
'dashboard.nextTrip': 'Next Trip',
|
||||
'dashboard.shared': 'Shared',
|
||||
'dashboard.sharedBy': 'Shared by {name}',
|
||||
'dashboard.days': 'Days',
|
||||
'dashboard.places': 'Places',
|
||||
'dashboard.archive': 'Archive',
|
||||
'dashboard.restore': 'Restore',
|
||||
'dashboard.archived': 'Archived',
|
||||
'dashboard.status.ongoing': 'Ongoing',
|
||||
'dashboard.status.today': 'Today',
|
||||
'dashboard.status.tomorrow': 'Tomorrow',
|
||||
'dashboard.status.past': 'Past',
|
||||
'dashboard.status.daysLeft': '{count} days left',
|
||||
'dashboard.toast.loadError': 'Failed to load trips',
|
||||
'dashboard.toast.created': 'Trip created successfully!',
|
||||
'dashboard.toast.createError': 'Failed to create trip',
|
||||
'dashboard.toast.updated': 'Trip updated!',
|
||||
'dashboard.toast.updateError': 'Failed to update trip',
|
||||
'dashboard.toast.deleted': 'Trip deleted',
|
||||
'dashboard.toast.deleteError': 'Failed to delete trip',
|
||||
'dashboard.toast.archived': 'Trip archived',
|
||||
'dashboard.toast.archiveError': 'Failed to archive trip',
|
||||
'dashboard.toast.restored': 'Trip restored',
|
||||
'dashboard.toast.restoreError': 'Failed to restore trip',
|
||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||
'dashboard.editTrip': 'Edit Trip',
|
||||
'dashboard.createTrip': 'Create New Trip',
|
||||
'dashboard.tripTitle': 'Title',
|
||||
'dashboard.tripTitlePlaceholder': 'e.g. Summer in Japan',
|
||||
'dashboard.tripDescription': 'Description',
|
||||
'dashboard.tripDescriptionPlaceholder': 'What is this trip about?',
|
||||
'dashboard.startDate': 'Start Date',
|
||||
'dashboard.endDate': 'End Date',
|
||||
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
||||
'dashboard.coverImage': 'Cover Image',
|
||||
'dashboard.addCoverImage': 'Add cover image',
|
||||
'dashboard.coverSaved': 'Cover image saved',
|
||||
'dashboard.coverUploadError': 'Failed to upload',
|
||||
'dashboard.coverRemoveError': 'Failed to remove',
|
||||
'dashboard.titleRequired': 'Title is required',
|
||||
'dashboard.endDateError': 'End date must be after start date',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
'settings.subtitle': 'Configure your personal settings',
|
||||
'settings.map': 'Map',
|
||||
'settings.mapTemplate': 'Map Template',
|
||||
'settings.mapTemplatePlaceholder.select': 'Select template...',
|
||||
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL template for map tiles',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Save Map',
|
||||
'settings.apiKeys': 'API Keys',
|
||||
'settings.mapsKey': 'Google Maps API Key',
|
||||
'settings.mapsKeyHint': 'For place search. Requires Places API (New). Get at console.cloud.google.com',
|
||||
'settings.weatherKey': 'OpenWeatherMap API Key',
|
||||
'settings.weatherKeyHint': 'For weather data. Free at openweathermap.org/api',
|
||||
'settings.keyPlaceholder': 'Enter key...',
|
||||
'settings.configured': 'Configured',
|
||||
'settings.saveKeys': 'Save Keys',
|
||||
'settings.display': 'Display',
|
||||
'settings.colorMode': 'Color Mode',
|
||||
'settings.light': 'Light',
|
||||
'settings.dark': 'Dark',
|
||||
'settings.language': 'Language',
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.account': 'Account',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Role',
|
||||
'settings.roleAdmin': 'Administrator',
|
||||
'settings.roleUser': 'User',
|
||||
'settings.saveProfile': 'Save Profile',
|
||||
'settings.toast.mapSaved': 'Map settings saved',
|
||||
'settings.toast.keysSaved': 'API keys saved',
|
||||
'settings.toast.displaySaved': 'Display settings saved',
|
||||
'settings.toast.profileSaved': 'Profile saved',
|
||||
'settings.uploadAvatar': 'Upload Profile Picture',
|
||||
'settings.removeAvatar': 'Remove Profile Picture',
|
||||
'settings.avatarUploaded': 'Profile picture updated',
|
||||
'settings.avatarRemoved': 'Profile picture removed',
|
||||
'settings.avatarError': 'Upload failed',
|
||||
|
||||
// Login
|
||||
'login.error': 'Login failed. Please check your credentials.',
|
||||
'login.tagline': 'Your Trips.\nYour Plan.',
|
||||
'login.description': 'Plan trips collaboratively with interactive maps, budgets, and real-time sync.',
|
||||
'login.features.maps': 'Interactive Maps',
|
||||
'login.features.mapsDesc': 'Google Places, routes & clustering',
|
||||
'login.features.realtime': 'Real-Time Sync',
|
||||
'login.features.realtimeDesc': 'Plan together via WebSocket',
|
||||
'login.features.budget': 'Budget Tracking',
|
||||
'login.features.budgetDesc': 'Categories, charts & per-person costs',
|
||||
'login.features.collab': 'Collaboration',
|
||||
'login.features.collabDesc': 'Multi-user with shared trips',
|
||||
'login.features.packing': 'Packing Lists',
|
||||
'login.features.packingDesc': 'Categories, progress & suggestions',
|
||||
'login.features.bookings': 'Reservations',
|
||||
'login.features.bookingsDesc': 'Flights, hotels, restaurants & more',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Upload & manage documents',
|
||||
'login.features.routes': 'Smart Routes',
|
||||
'login.features.routesDesc': 'Auto-optimize & Google Maps export',
|
||||
'login.selfHosted': 'Self-hosted \u00B7 Open Source \u00B7 Your data stays yours',
|
||||
'login.title': 'Sign In',
|
||||
'login.subtitle': 'Welcome back',
|
||||
'login.signingIn': 'Signing in…',
|
||||
'login.signIn': 'Sign In',
|
||||
'login.createAdmin': 'Create Admin Account',
|
||||
'login.createAdminHint': 'Set up the first admin account for NOMAD.',
|
||||
'login.createAccount': 'Create Account',
|
||||
'login.createAccountHint': 'Register a new account.',
|
||||
'login.creating': 'Creating…',
|
||||
'login.noAccount': "Don't have an account?",
|
||||
'login.hasAccount': 'Already have an account?',
|
||||
'login.register': 'Register',
|
||||
'login.emailPlaceholder': 'your@email.com',
|
||||
'login.username': 'Username',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Administration',
|
||||
'admin.subtitle': 'User management and system settings',
|
||||
'admin.tabs.users': 'Users',
|
||||
'admin.tabs.categories': 'Categories',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.stats.users': 'Users',
|
||||
'admin.stats.trips': 'Trips',
|
||||
'admin.stats.places': 'Places',
|
||||
'admin.stats.photos': 'Photos',
|
||||
'admin.stats.files': 'Files',
|
||||
'admin.table.user': 'User',
|
||||
'admin.table.email': 'Email',
|
||||
'admin.table.role': 'Role',
|
||||
'admin.table.created': 'Created',
|
||||
'admin.table.actions': 'Actions',
|
||||
'admin.you': '(You)',
|
||||
'admin.editUser': 'Edit User',
|
||||
'admin.newPassword': 'New Password',
|
||||
'admin.newPasswordHint': 'Leave empty to keep current password',
|
||||
'admin.deleteUser': 'Delete user "{name}"? All trips will be permanently deleted.',
|
||||
'admin.deleteUserTitle': 'Delete user',
|
||||
'admin.newPasswordPlaceholder': 'Enter new password…',
|
||||
'admin.toast.loadError': 'Failed to load admin data',
|
||||
'admin.toast.userUpdated': 'User updated',
|
||||
'admin.toast.updateError': 'Failed to update',
|
||||
'admin.toast.userDeleted': 'User deleted',
|
||||
'admin.toast.deleteError': 'Failed to delete',
|
||||
'admin.toast.cannotDeleteSelf': 'Cannot delete your own account',
|
||||
'admin.toast.userCreated': 'User created',
|
||||
'admin.toast.createError': 'Failed to create user',
|
||||
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
||||
'admin.createUser': 'Create User',
|
||||
'admin.tabs.settings': 'Settings',
|
||||
'admin.allowRegistration': 'Allow Registration',
|
||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||
'admin.apiKeys': 'API Keys',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
|
||||
'admin.weatherKey': 'OpenWeatherMap API Key',
|
||||
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
|
||||
'admin.validateKey': 'Test',
|
||||
'admin.keyValid': 'Connected',
|
||||
'admin.keyInvalid': 'Invalid',
|
||||
'admin.keySaved': 'API keys saved',
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.reservations': 'Bookings',
|
||||
'trip.tabs.packing': 'Packing List',
|
||||
'trip.tabs.packingShort': 'Packing',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Files',
|
||||
'trip.loading': 'Loading trip...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Places',
|
||||
'trip.toast.placeUpdated': 'Place updated',
|
||||
'trip.toast.placeAdded': 'Place added',
|
||||
'trip.toast.placeDeleted': 'Place deleted',
|
||||
'trip.toast.selectDay': 'Please select a day first',
|
||||
'trip.toast.assignedToDay': 'Place assigned to day',
|
||||
'trip.toast.reorderError': 'Failed to reorder',
|
||||
'trip.toast.reservationUpdated': 'Reservation updated',
|
||||
'trip.toast.reservationAdded': 'Reservation added',
|
||||
'trip.toast.deleted': 'Deleted',
|
||||
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.transport.car': 'Car',
|
||||
'dayplan.transport.walk': 'Walk',
|
||||
'dayplan.transport.bike': 'Bike',
|
||||
'dayplan.emptyDay': 'No places planned for this day',
|
||||
'dayplan.addNote': 'Add Note',
|
||||
'dayplan.editNote': 'Edit Note',
|
||||
'dayplan.noteAdd': 'Add Note',
|
||||
'dayplan.noteEdit': 'Edit Note',
|
||||
'dayplan.noteTitle': 'Note',
|
||||
'dayplan.noteSubtitle': 'Daily Note',
|
||||
'dayplan.totalCost': 'Total Cost',
|
||||
'dayplan.days': 'Days',
|
||||
'dayplan.dayN': 'Day {n}',
|
||||
'dayplan.calculating': 'Calculating...',
|
||||
'dayplan.route': 'Route',
|
||||
'dayplan.optimize': 'Optimize',
|
||||
'dayplan.optimized': 'Route optimized',
|
||||
'dayplan.routeError': 'Failed to calculate route',
|
||||
'dayplan.confirmed': 'Confirmed',
|
||||
'dayplan.pendingRes': 'Pending',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Export day plan as PDF',
|
||||
'dayplan.pdfError': 'Failed to export PDF',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.count': '{count} places',
|
||||
'places.countSingular': '1 place',
|
||||
'places.allPlanned': 'All places are planned',
|
||||
'places.noneFound': 'No places found',
|
||||
'places.editPlace': 'Edit Place',
|
||||
'places.formName': 'Name',
|
||||
'places.formNamePlaceholder': 'e.g. Eiffel Tower',
|
||||
'places.formDescription': 'Description',
|
||||
'places.formDescriptionPlaceholder': 'Short description...',
|
||||
'places.formAddress': 'Address',
|
||||
'places.formAddressPlaceholder': 'Street, City, Country',
|
||||
'places.formLat': 'Latitude (e.g. 48.8566)',
|
||||
'places.formLng': 'Longitude (e.g. 2.3522)',
|
||||
'places.formCategory': 'Category',
|
||||
'places.noCategory': 'No Category',
|
||||
'places.categoryNamePlaceholder': 'Category name',
|
||||
'places.formTime': 'Time',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Personal notes...',
|
||||
'places.formReservation': 'Reservation',
|
||||
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
||||
'places.mapsSearchPlaceholder': 'Search Google Maps...',
|
||||
'places.mapsSearchError': 'Google Maps search failed. Please add an API key in settings.',
|
||||
'places.categoryCreateError': 'Failed to create category',
|
||||
'places.nameRequired': 'Please enter a name',
|
||||
'places.saveError': 'Failed to save',
|
||||
'places.transport.walking': '🚶 Walking',
|
||||
'places.transport.driving': '🚗 Driving',
|
||||
'places.transport.cycling': '🚲 Cycling',
|
||||
'places.transport.transit': '🚌 Transit',
|
||||
|
||||
// Place Inspector
|
||||
'inspector.opened': 'Open',
|
||||
'inspector.closed': 'Closed',
|
||||
'inspector.openingHours': 'Opening Hours',
|
||||
'inspector.showHours': 'Show opening hours',
|
||||
'inspector.files': 'Files',
|
||||
'inspector.filesCount': '{count} files',
|
||||
'inspector.removeFromDay': 'Remove from Day',
|
||||
'inspector.addToDay': 'Add to Day',
|
||||
'inspector.confirmedRes': 'Confirmed Reservation',
|
||||
'inspector.pendingRes': 'Pending Reservation',
|
||||
'inspector.google': 'Open in Google Maps',
|
||||
'inspector.website': 'Open Website',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Bookings',
|
||||
'reservations.empty': 'No reservations yet',
|
||||
'reservations.emptyHint': 'Add reservations for flights, hotels and more',
|
||||
'reservations.add': 'Add Reservation',
|
||||
'reservations.addManual': 'Manual Booking',
|
||||
'reservations.placeHint': 'Tip: Reservations are best created directly from a place to link them with your day plan.',
|
||||
'reservations.confirmed': 'Confirmed',
|
||||
'reservations.pending': 'Pending',
|
||||
'reservations.summary': '{confirmed} confirmed, {pending} pending',
|
||||
'reservations.fromPlan': 'From Plan',
|
||||
'reservations.showFiles': 'Show Files',
|
||||
'reservations.editTitle': 'Edit Reservation',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.datetime': 'Date & Time',
|
||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||
'reservations.notes': 'Notes',
|
||||
'reservations.notesPlaceholder': 'Additional notes...',
|
||||
'reservations.type.flight': 'Flight',
|
||||
'reservations.type.hotel': 'Hotel',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Train',
|
||||
'reservations.type.car': 'Rental Car',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Event',
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Other',
|
||||
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
||||
'reservations.toast.updated': 'Reservation updated',
|
||||
'reservations.toast.removed': 'Reservation deleted',
|
||||
'reservations.toast.fileUploaded': 'File uploaded',
|
||||
'reservations.toast.uploadError': 'Failed to upload',
|
||||
'reservations.newTitle': 'New Reservation',
|
||||
'reservations.bookingType': 'Booking Type',
|
||||
'reservations.titleLabel': 'Title',
|
||||
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
|
||||
'reservations.locationAddress': 'Location / Address',
|
||||
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
|
||||
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
|
||||
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
|
||||
'reservations.day': 'Day',
|
||||
'reservations.noDay': 'No Day',
|
||||
'reservations.place': 'Place',
|
||||
'reservations.noPlace': 'No Place',
|
||||
'reservations.pendingSave': 'will be saved…',
|
||||
'reservations.uploading': 'Uploading...',
|
||||
'reservations.attachFile': 'Attach file',
|
||||
'reservations.toast.saveError': 'Failed to save',
|
||||
'reservations.toast.updateError': 'Failed to update',
|
||||
'reservations.toast.deleteError': 'Failed to delete',
|
||||
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.emptyTitle': 'No budget created yet',
|
||||
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
||||
'budget.emptyPlaceholder': 'Enter category name...',
|
||||
'budget.createCategory': 'Create Category',
|
||||
'budget.category': 'Category',
|
||||
'budget.categoryName': 'Category Name',
|
||||
'budget.table.name': 'Name',
|
||||
'budget.table.total': 'Total',
|
||||
'budget.table.persons': 'Persons',
|
||||
'budget.table.days': 'Days',
|
||||
'budget.table.perPerson': 'Per Person',
|
||||
'budget.table.perDay': 'Per Day',
|
||||
'budget.table.perPersonDay': 'Per Person/Day',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.newEntry': 'New Entry',
|
||||
'budget.defaultEntry': 'New Entry',
|
||||
'budget.defaultCategory': 'New Category',
|
||||
'budget.total': 'Total',
|
||||
'budget.totalBudget': 'Total Budget',
|
||||
'budget.byCategory': 'By Category',
|
||||
'budget.editTooltip': 'Click to edit',
|
||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
||||
'budget.deleteCategory': 'Delete Category',
|
||||
|
||||
// Files
|
||||
'files.title': 'Files',
|
||||
'files.count': '{count} files',
|
||||
'files.countSingular': '1 file',
|
||||
'files.uploaded': '{count} uploaded',
|
||||
'files.uploadError': 'Upload failed',
|
||||
'files.dropzone': 'Drop files here',
|
||||
'files.dropzoneHint': 'or click to browse',
|
||||
'files.uploading': 'Uploading...',
|
||||
'files.filterAll': 'All',
|
||||
'files.filterPdf': 'PDFs',
|
||||
'files.filterImages': 'Images',
|
||||
'files.filterDocs': 'Documents',
|
||||
'files.empty': 'No files yet',
|
||||
'files.emptyHint': 'Upload files to attach them to your trip',
|
||||
'files.openTab': 'Open in new tab',
|
||||
'files.confirm.delete': 'Are you sure you want to delete this file?',
|
||||
'files.toast.deleted': 'File deleted',
|
||||
'files.toast.deleteError': 'Failed to delete file',
|
||||
'files.sourcePlan': 'Day Plan',
|
||||
'files.sourceBooking': 'Booking',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Packing List',
|
||||
'packing.empty': 'Packing list is empty',
|
||||
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
||||
'packing.clearChecked': 'Remove {count} checked',
|
||||
'packing.clearCheckedShort': 'Remove {count}',
|
||||
'packing.suggestions': 'Suggestions',
|
||||
'packing.suggestionsTitle': 'Add Suggestions',
|
||||
'packing.allSuggested': 'All suggestions added',
|
||||
'packing.allPacked': 'All packed!',
|
||||
'packing.addPlaceholder': 'Add new item...',
|
||||
'packing.categoryPlaceholder': 'Category...',
|
||||
'packing.filterAll': 'All',
|
||||
'packing.filterOpen': 'Open',
|
||||
'packing.filterDone': 'Done',
|
||||
'packing.emptyTitle': 'Packing list is empty',
|
||||
'packing.emptyHint': 'Add items or use the suggestions',
|
||||
'packing.emptyFiltered': 'No items match this filter',
|
||||
'packing.menuRename': 'Rename',
|
||||
'packing.menuCheckAll': 'Check All',
|
||||
'packing.menuUncheckAll': 'Uncheck All',
|
||||
'packing.menuDeleteCat': 'Delete Category',
|
||||
'packing.changeCategory': 'Change Category',
|
||||
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
|
||||
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
|
||||
'packing.defaultCategory': 'Other',
|
||||
'packing.toast.saveError': 'Failed to save',
|
||||
'packing.toast.deleteError': 'Failed to delete',
|
||||
'packing.toast.renameError': 'Failed to rename',
|
||||
'packing.toast.addError': 'Failed to add',
|
||||
|
||||
// Packing suggestions
|
||||
'packing.suggestions.items': [
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
{ name: 'ID Card', category: 'Documents' },
|
||||
{ name: 'Travel Insurance', category: 'Documents' },
|
||||
{ name: 'Flight Tickets', category: 'Documents' },
|
||||
{ name: 'Credit Card', category: 'Finances' },
|
||||
{ name: 'Cash', category: 'Finances' },
|
||||
{ name: 'Visa', category: 'Documents' },
|
||||
{ name: 'T-Shirts', category: 'Clothing' },
|
||||
{ name: 'Pants', category: 'Clothing' },
|
||||
{ name: 'Underwear', category: 'Clothing' },
|
||||
{ name: 'Socks', category: 'Clothing' },
|
||||
{ name: 'Jacket', category: 'Clothing' },
|
||||
{ name: 'Sleepwear', category: 'Clothing' },
|
||||
{ name: 'Swimwear', category: 'Clothing' },
|
||||
{ name: 'Rain Jacket', category: 'Clothing' },
|
||||
{ name: 'Comfortable Shoes', category: 'Clothing' },
|
||||
{ name: 'Toothbrush', category: 'Toiletries' },
|
||||
{ name: 'Toothpaste', category: 'Toiletries' },
|
||||
{ name: 'Shampoo', category: 'Toiletries' },
|
||||
{ name: 'Deodorant', category: 'Toiletries' },
|
||||
{ name: 'Sunscreen', category: 'Toiletries' },
|
||||
{ name: 'Razor', category: 'Toiletries' },
|
||||
{ name: 'Charger', category: 'Electronics' },
|
||||
{ name: 'Power Bank', category: 'Electronics' },
|
||||
{ name: 'Headphones', category: 'Electronics' },
|
||||
{ name: 'Travel Adapter', category: 'Electronics' },
|
||||
{ name: 'Camera', category: 'Electronics' },
|
||||
{ name: 'Pain Medication', category: 'Health' },
|
||||
{ name: 'Band-Aids', category: 'Health' },
|
||||
{ name: 'Disinfectant', category: 'Health' },
|
||||
],
|
||||
|
||||
// Members / Sharing
|
||||
'members.shareTrip': 'Share Trip',
|
||||
'members.inviteUser': 'Invite User',
|
||||
'members.selectUser': 'Select user…',
|
||||
'members.invite': 'Invite',
|
||||
'members.allHaveAccess': 'All users already have access.',
|
||||
'members.access': 'Access',
|
||||
'members.person': 'person',
|
||||
'members.persons': 'persons',
|
||||
'members.you': 'you',
|
||||
'members.owner': 'Owner',
|
||||
'members.leaveTrip': 'Leave trip',
|
||||
'members.removeAccess': 'Remove access',
|
||||
'members.confirmLeave': 'Leave trip? You will lose access.',
|
||||
'members.confirmRemove': 'Remove access for this user?',
|
||||
'members.loadError': 'Failed to load members',
|
||||
'members.added': 'added',
|
||||
'members.addError': 'Failed to add',
|
||||
'members.removed': 'Member removed',
|
||||
'members.removeError': 'Failed to remove',
|
||||
|
||||
// Categories (Admin)
|
||||
'categories.title': 'Categories',
|
||||
'categories.subtitle': 'Manage categories for places',
|
||||
'categories.new': 'New Category',
|
||||
'categories.empty': 'No categories yet',
|
||||
'categories.namePlaceholder': 'Category name',
|
||||
'categories.icon': 'Icon',
|
||||
'categories.color': 'Color',
|
||||
'categories.customColor': 'Choose custom color',
|
||||
'categories.preview': 'Preview',
|
||||
'categories.defaultName': 'Category',
|
||||
'categories.update': 'Update',
|
||||
'categories.create': 'Create',
|
||||
'categories.confirm.delete': 'Delete category? Places in this category will not be deleted.',
|
||||
'categories.toast.loadError': 'Failed to load categories',
|
||||
'categories.toast.nameRequired': 'Please enter a name',
|
||||
'categories.toast.updated': 'Category updated',
|
||||
'categories.toast.created': 'Category created',
|
||||
'categories.toast.saveError': 'Failed to save',
|
||||
'categories.toast.deleted': 'Category deleted',
|
||||
'categories.toast.deleteError': 'Failed to delete',
|
||||
|
||||
// Backup (Admin)
|
||||
'backup.title': 'Data Backup',
|
||||
'backup.subtitle': 'Database and all uploaded files',
|
||||
'backup.refresh': 'Refresh',
|
||||
'backup.upload': 'Upload Backup',
|
||||
'backup.uploading': 'Uploading…',
|
||||
'backup.create': 'Create Backup',
|
||||
'backup.creating': 'Creating…',
|
||||
'backup.empty': 'No backups yet',
|
||||
'backup.createFirst': 'Create first backup',
|
||||
'backup.download': 'Download',
|
||||
'backup.restore': 'Restore',
|
||||
'backup.confirm.restore': 'Restore backup "{name}"?\n\nAll current data will be replaced with the backup.',
|
||||
'backup.confirm.uploadRestore': 'Upload and restore backup file "{name}"?\n\nAll current data will be overwritten.',
|
||||
'backup.confirm.delete': 'Delete backup "{name}"?',
|
||||
'backup.toast.loadError': 'Failed to load backups',
|
||||
'backup.toast.created': 'Backup created successfully',
|
||||
'backup.toast.createError': 'Failed to create backup',
|
||||
'backup.toast.restored': 'Backup restored. Page will reload…',
|
||||
'backup.toast.restoreError': 'Failed to restore',
|
||||
'backup.toast.uploadError': 'Failed to upload',
|
||||
'backup.toast.deleted': 'Backup deleted',
|
||||
'backup.toast.deleteError': 'Failed to delete',
|
||||
'backup.toast.downloadError': 'Download failed',
|
||||
'backup.toast.settingsSaved': 'Auto-backup settings saved',
|
||||
'backup.toast.settingsError': 'Failed to save settings',
|
||||
'backup.auto.title': 'Auto-Backup',
|
||||
'backup.auto.subtitle': 'Automatic backup on a schedule',
|
||||
'backup.auto.enable': 'Enable auto-backup',
|
||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||
'backup.auto.interval': 'Interval',
|
||||
'backup.auto.keepLabel': 'Delete old backups after',
|
||||
'backup.interval.hourly': 'Hourly',
|
||||
'backup.interval.daily': 'Daily',
|
||||
'backup.interval.weekly': 'Weekly',
|
||||
'backup.interval.monthly': 'Monthly',
|
||||
'backup.keep.1day': '1 day',
|
||||
'backup.keep.3days': '3 days',
|
||||
'backup.keep.7days': '7 days',
|
||||
'backup.keep.14days': '14 days',
|
||||
'backup.keep.30days': '30 days',
|
||||
'backup.keep.forever': 'Keep forever',
|
||||
|
||||
// PDF
|
||||
'pdf.travelPlan': 'Travel Plan',
|
||||
'pdf.planned': 'Planned',
|
||||
'pdf.costLabel': 'Cost EUR',
|
||||
'pdf.preview': 'PDF Preview',
|
||||
'pdf.saveAsPdf': 'Save as PDF',
|
||||
|
||||
// Dashboard Stats
|
||||
'stats.countries': 'Countries',
|
||||
'stats.cities': 'Cities',
|
||||
'stats.trips': 'Trips',
|
||||
'stats.places': 'Places',
|
||||
'stats.worldProgress': 'World Progress',
|
||||
'stats.visited': 'visited',
|
||||
'stats.remaining': 'remaining',
|
||||
'stats.visitedCountries': 'Visited Countries',
|
||||
}
|
||||
|
||||
export default en
|
||||
@@ -0,0 +1,347 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
|
||||
.reorder-buttons {
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
}
|
||||
.reorder-buttons button {
|
||||
background: none !important;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.reorder-buttons {
|
||||
flex-direction: row !important;
|
||||
opacity: 1 !important;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.reorder-buttons button {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.note-edit-buttons {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
|
||||
.place-row:hover .reorder-btns {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Ladeanimation */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Design tokens ─────────────────────────────── */
|
||||
:root {
|
||||
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-6: 24px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.1);
|
||||
|
||||
/* Theme colors */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-elevated: rgba(250,250,250,0.82);
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--bg-hover: rgba(0,0,0,0.03);
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-faint: #9ca3af;
|
||||
--border-primary: #e5e7eb;
|
||||
--border-secondary: #f3f4f6;
|
||||
--border-faint: rgba(0,0,0,0.06);
|
||||
--accent: #111827;
|
||||
--accent-text: #ffffff;
|
||||
--sidebar-bg: rgba(250,250,250,0.82);
|
||||
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06);
|
||||
--tooltip-bg: rgba(255,255,255,0.96);
|
||||
--scrollbar-track: #f1f5f9;
|
||||
--scrollbar-thumb: #d1d5db;
|
||||
--scrollbar-hover: #9ca3af;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-primary: #121215;
|
||||
--bg-secondary: #1a1a1e;
|
||||
--bg-tertiary: #1c1c21;
|
||||
--bg-elevated: rgba(19,19,22,0.82);
|
||||
--bg-card: #131316;
|
||||
--bg-input: #1c1c21;
|
||||
--bg-hover: rgba(255,255,255,0.06);
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #d4d4d8;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-faint: #71717a;
|
||||
--border-primary: #27272a;
|
||||
--border-secondary: #1c1c21;
|
||||
--border-faint: rgba(255,255,255,0.07);
|
||||
--accent: #e4e4e7;
|
||||
--accent-text: #09090b;
|
||||
--sidebar-bg: rgba(19,19,22,0.82);
|
||||
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
|
||||
--tooltip-bg: rgba(19,19,22,0.96);
|
||||
--scrollbar-track: #131316;
|
||||
--scrollbar-thumb: #3f3f46;
|
||||
--scrollbar-hover: #52525b;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-system);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* ── Marker cluster custom styling ────────────── */
|
||||
.marker-cluster-wrapper {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-cluster-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #111827;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25), 0 0 0 2px rgba(17, 24, 39, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.marker-cluster-custom:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), 0 0 0 3px rgba(17, 24, 39, 0.2);
|
||||
}
|
||||
|
||||
.marker-cluster-custom span {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hide default markercluster styles we don't need */
|
||||
.marker-cluster-small,
|
||||
.marker-cluster-medium,
|
||||
.marker-cluster-large {
|
||||
background: transparent !important;
|
||||
}
|
||||
.marker-cluster-small div,
|
||||
.marker-cluster-medium div,
|
||||
.marker-cluster-large div {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Leaflet z-index ───────────────────────────── */
|
||||
.leaflet-container {
|
||||
z-index: 0;
|
||||
}
|
||||
.leaflet-pane {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
.leaflet-top, .leaflet-bottom {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* ── iOS-style map tooltip ─────────────────────── */
|
||||
.leaflet-tooltip.map-tooltip {
|
||||
background: var(--tooltip-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-system);
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.leaflet-tooltip.map-tooltip::before {
|
||||
border-right-color: var(--tooltip-bg);
|
||||
}
|
||||
.leaflet-tooltip-left.map-tooltip::before {
|
||||
border-left-color: var(--tooltip-bg);
|
||||
}
|
||||
|
||||
/* Scrollbalken */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
/* Einheitliche Formular-Inputs */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
.form-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Weiche Übergänge */
|
||||
.transition-smooth {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Tagesliste */
|
||||
.day-list-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.day-list-item:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.day-list-item.active {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
|
||||
.dark .bg-slate-50 { background-color: #131316 !important; }
|
||||
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
|
||||
.dark .bg-white { background-color: #09090b !important; }
|
||||
.dark .bg-gray-50 { background-color: #131316 !important; }
|
||||
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
|
||||
.dark .text-slate-900, .dark .text-gray-900, .dark .text-slate-800 { color: #f4f4f5 !important; }
|
||||
.dark .text-slate-700, .dark .text-gray-700 { color: #d4d4d8 !important; }
|
||||
.dark .text-slate-600, .dark .text-gray-600 { color: #a1a1aa !important; }
|
||||
.dark .text-slate-500, .dark .text-gray-500 { color: #a1a1aa !important; }
|
||||
.dark .text-slate-400, .dark .text-gray-400 { color: #71717a !important; }
|
||||
.dark .border-slate-200, .dark .border-gray-200, .dark .border-slate-300 { border-color: #27272a !important; }
|
||||
.dark .border-gray-100, .dark .border-slate-100 { border-color: #1c1c21 !important; }
|
||||
.dark .hover\:bg-slate-50:hover, .dark .hover\:bg-gray-50:hover { background-color: #1c1c21 !important; }
|
||||
.dark .bg-slate-50\/60 { background-color: transparent !important; }
|
||||
.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: #27272a !important; }
|
||||
.dark .bg-slate-100 { background-color: #27272a !important; }
|
||||
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
|
||||
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
|
||||
.dark input, .dark textarea, .dark select {
|
||||
background-color: #1c1c21;
|
||||
color: #f4f4f5;
|
||||
border-color: #27272a;
|
||||
}
|
||||
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
|
||||
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
|
||||
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
|
||||
|
||||
/* Ladebildschirm */
|
||||
.dark .min-h-screen { background-color: var(--bg-primary) !important; }
|
||||
|
||||
/* Modal-Hintergrund */
|
||||
.dark .modal-backdrop { background: rgba(0,0,0,0.6); }
|
||||
|
||||
/* ── Dark: Fallback für Komponenten die noch nicht auf CSS-Variablen umgestellt sind ── */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scroll-Container */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db #f1f5f9;
|
||||
}
|
||||
|
||||
/* Toast-Animationen */
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-out-right {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
.toast-enter {
|
||||
animation: slide-in-right 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
animation: slide-out-right 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Modal-Hintergrund */
|
||||
.modal-backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Fortschrittsbalken */
|
||||
@keyframes progress-fill {
|
||||
from { width: 0; }
|
||||
to { width: var(--progress); }
|
||||
}
|
||||
|
||||
/* Anmeldeseiten-Gradient */
|
||||
.auth-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Bildanzeige-Overlay */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,658 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminApi, authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import CategoryManager from '../components/Admin/CategoryManager'
|
||||
import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { demoMode } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'categories', label: t('admin.tabs.categories') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('users')
|
||||
const [users, setUsers] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
|
||||
const [showCreateUser, setShowCreateUser] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||
|
||||
// API Keys
|
||||
const [mapsKey, setMapsKey] = useState('')
|
||||
const [weatherKey, setWeatherKey] = useState('')
|
||||
const [showKeys, setShowKeys] = useState({})
|
||||
const [savingKeys, setSavingKeys] = useState(false)
|
||||
const [validating, setValidating] = useState({})
|
||||
const [validation, setValidation] = useState({})
|
||||
|
||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadAppConfig()
|
||||
loadApiKeys()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [usersData, statsData] = await Promise.all([
|
||||
adminApi.users(),
|
||||
adminApi.stats(),
|
||||
])
|
||||
setUsers(usersData.users)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
toast.error(t('admin.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAppConfig = async () => {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const data = await authApi.getSettings()
|
||||
setMapsKey(data.settings?.maps_api_key || '')
|
||||
setWeatherKey(data.settings?.openweather_api_key || '')
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (value) => {
|
||||
setAllowRegistration(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allow_registration: value })
|
||||
} catch (err) {
|
||||
setAllowRegistration(!value)
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const handleSaveApiKeys = async () => {
|
||||
setSavingKeys(true)
|
||||
try {
|
||||
await updateApiKeys({
|
||||
maps_api_key: mapsKey,
|
||||
openweather_api_key: weatherKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSavingKeys(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateKeys = async () => {
|
||||
setValidating({ maps: true, weather: true })
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating({})
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateKey = async (keyType) => {
|
||||
setValidating(prev => ({ ...prev, [keyType]: true }))
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating(prev => ({ ...prev, [keyType]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
|
||||
toast.error(t('admin.toast.fieldsRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await adminApi.createUser(createForm)
|
||||
setUsers(prev => [data.user, ...prev])
|
||||
setShowCreateUser(false)
|
||||
setCreateForm({ username: '', email: '', password: '', role: 'user' })
|
||||
toast.success(t('admin.toast.userCreated'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.createError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
|
||||
}
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
username: editForm.username.trim() || undefined,
|
||||
email: editForm.email.trim() || undefined,
|
||||
role: editForm.role,
|
||||
}
|
||||
if (editForm.password.trim()) payload.password = editForm.password.trim()
|
||||
const data = await adminApi.updateUser(editingUser.id, payload)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
||||
setEditingUser(null)
|
||||
toast.success(t('admin.toast.userUpdated'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (user.id === currentUser?.id) {
|
||||
toast.error(t('admin.toast.cannotDeleteSelf'))
|
||||
return
|
||||
}
|
||||
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
|
||||
try {
|
||||
await adminApi.deleteUser(user.id)
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id))
|
||||
toast.success(t('admin.toast.userDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-slate-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
|
||||
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Baseline Button */}
|
||||
{demoMode && (
|
||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-900">Demo Baseline</p>
|
||||
<p className="text-xs text-amber-700">Save current state as the hourly reset point. All admin trips and settings will be preserved.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await adminApi.saveDemoBaseline()
|
||||
toast.success('Baseline saved! Resets will restore to this state.')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.error || 'Failed to save baseline')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-semibold hover:bg-amber-700 transition-colors flex-shrink-0 ml-4"
|
||||
>
|
||||
Save Baseline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
|
||||
{[
|
||||
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
||||
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-white border border-slate-200 rounded-xl p-1 w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateUser(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t('admin.createUser')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-5 py-3">{t('admin.table.user')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.email')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.role')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.created')}</th>
|
||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{u.username}</p>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="text-xs text-slate-500">{t('admin.you')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-600">{u.email}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${
|
||||
u.role === 'admin'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{u.role === 'admin' && <Shield className="w-3 h-3" />}
|
||||
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => handleEditUser(u)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title={t('admin.editUser')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={t('admin.deleteUserTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'categories' && <CategoryManager />}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="space-y-6">
|
||||
{/* Registration Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.allowRegistration')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
allowRegistration ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Google Maps Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.maps ? 'text' : 'password'}
|
||||
value={mapsKey}
|
||||
onChange={e => setMapsKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('maps')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.maps ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('maps')}
|
||||
disabled={!mapsKey || validating.maps}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.maps ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.maps === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.maps === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
|
||||
{validation.maps === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.maps === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenWeatherMap Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.weather ? 'text' : 'password'}
|
||||
value={weatherKey}
|
||||
onChange={e => setWeatherKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('weather')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('weather')}
|
||||
disabled={!weatherKey || validating.weather}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.weather ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.weather === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.weather === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
|
||||
{validation.weather === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.weather === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveApiKeys}
|
||||
disabled={savingKeys}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{savingKeys ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create user modal */}
|
||||
<Modal
|
||||
isOpen={showCreateUser}
|
||||
onClose={() => setShowCreateUser(false)}
|
||||
title={t('admin.createUser')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowCreateUser(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
||||
>
|
||||
{t('admin.createUser')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.username}
|
||||
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder={t('settings.username')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder={t('common.email')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
<CustomSelect
|
||||
value={createForm.role}
|
||||
onChange={value => setCreateForm(f => ({ ...f, role: value }))}
|
||||
options={[
|
||||
{ value: 'user', label: t('settings.roleUser') },
|
||||
{ value: 'admin', label: t('settings.roleAdmin') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit user modal */}
|
||||
<Modal
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
title={t('admin.editUser')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveUser}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editingUser && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.username}
|
||||
onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
<CustomSelect
|
||||
value={editForm.role}
|
||||
onChange={value => setEditForm(f => ({ ...f, role: value }))}
|
||||
options={[
|
||||
{ value: 'user', label: t('settings.roleUser') },
|
||||
{ value: 'admin', label: t('settings.roleAdmin') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { tripsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
import TravelStats from '../components/Dashboard/TravelStats'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
|
||||
return Math.round((d - today) / 86400000)
|
||||
}
|
||||
|
||||
function getTripStatus(trip) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
|
||||
const until = daysUntil(trip.start_date)
|
||||
if (until === null) return null
|
||||
if (until === 0) return 'today'
|
||||
if (until === 1) return 'tomorrow'
|
||||
if (until > 1) return 'future'
|
||||
return 'past'
|
||||
}
|
||||
|
||||
function formatDate(dateStr, locale = 'de-DE') {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr, locale = 'de-DE') {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function sortTrips(trips) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
function rank(t) {
|
||||
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
|
||||
if (t.start_date && t.start_date >= today) return 1 // upcoming
|
||||
return 2 // past
|
||||
}
|
||||
return [...trips].sort((a, b) => {
|
||||
const ra = rank(a), rb = rank(b)
|
||||
if (ra !== rb) return ra - rb
|
||||
const ad = a.start_date || '', bd = b.start_date || ''
|
||||
if (ra <= 1) return ad.localeCompare(bd)
|
||||
return bd.localeCompare(ad)
|
||||
})
|
||||
}
|
||||
|
||||
// Gradient backgrounds when no cover image
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
||||
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
|
||||
]
|
||||
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
|
||||
|
||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
|
||||
onClick={() => onClick(trip)}>
|
||||
{/* Cover / Background */}
|
||||
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Badges top-left */}
|
||||
<div style={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 8 }}>
|
||||
{status && (
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
|
||||
color: 'white', fontSize: 12, fontWeight: 700,
|
||||
padding: '5px 12px', borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
||||
</div>
|
||||
|
||||
{/* Bottom content */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
|
||||
{trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })}
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'white', lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{trip.title}
|
||||
</h2>
|
||||
{trip.description && (
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,0.75)', lineHeight: 1.4, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{trip.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 12 }}>
|
||||
{trip.start_date && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Calendar size={13} />
|
||||
{formatDateShort(trip.start_date, locale)}
|
||||
{trip.end_date && <> — {formatDateShort(trip.end_date, locale)}</>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Clock size={13} /> {trip.day_count || 0} {t('dashboard.days')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onClick(trip)}
|
||||
style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
|
||||
border: '1px solid var(--border-primary)', transition: 'all 0.18s',
|
||||
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Image area */}
|
||||
<div style={{ height: 120, background: coverBg, position: 'relative', overflow: 'hidden' }}>
|
||||
{trip.cover_image && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.35) 0%, transparent 60%)' }} />}
|
||||
|
||||
{/* Status badge */}
|
||||
{status && (
|
||||
<div style={{ position: 'absolute', top: 8, left: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
|
||||
background: 'rgba(0,0,0,0.4)', color: 'white', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', marginBottom: 3 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
{!trip.is_owner && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{t('dashboard.shared')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trip.description && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
<Calendar size={11} style={{ flexShrink: 0 }} />
|
||||
{trip.start_date && trip.end_date
|
||||
? `${formatDateShort(trip.start_date, locale)} — ${formatDateShort(trip.end_date, locale)}`
|
||||
: formatDate(trip.start_date || trip.end_date, locale)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
borderRadius: 12, border: '1px solid #f3f4f6', background: 'white', cursor: 'pointer',
|
||||
transition: 'border-color 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
|
||||
{/* Mini cover */}
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
background: trip.cover_image ? `url(${trip.cover_image}) center/cover no-repeat` : tripGradient(trip.id),
|
||||
opacity: 0.7,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
||||
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
||||
</div>
|
||||
{trip.start_date && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
||||
{formatDateShort(trip.start_date, locale)}{trip.end_date ? ` — ${formatDateShort(trip.end_date, locale)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#6b7280' }}>
|
||||
<ArchiveRestore size={12} /> {t('dashboard.restore')}
|
||||
</button>
|
||||
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#9ca3af' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ onClick, icon, label, danger }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11,
|
||||
color: danger ? '#9ca3af' : '#9ca3af', fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = danger ? '#fef2f2' : '#f3f4f6'; e.currentTarget.style.color = danger ? '#ef4444' : '#374151' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, danger, loading, children }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={loading} style={{
|
||||
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: danger ? '#fca5a5' : 'white', transition: 'background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = danger ? 'rgba(239,68,68,0.5)' : 'rgba(255,255,255,0.25)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.3)'}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
|
||||
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
|
||||
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [trips, setTrips] = useState([])
|
||||
const [archivedTrips, setArchivedTrips] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingTrip, setEditingTrip] = useState(null)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
|
||||
useEffect(() => { loadTrips() }, [])
|
||||
|
||||
const loadTrips = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
setTrips(sortTrips(active.trips))
|
||||
setArchivedTrips(sortTrips(archived.trips))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (tripData) => {
|
||||
try {
|
||||
const data = await tripsApi.create(tripData)
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.created'))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (tripData) => {
|
||||
try {
|
||||
const data = await tripsApi.update(editingTrip.id, tripData)
|
||||
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
|
||||
toast.success(t('dashboard.toast.updated'))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (trip) => {
|
||||
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
|
||||
try {
|
||||
await tripsApi.delete(trip.id)
|
||||
setTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
toast.success(t('dashboard.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.archive(id)
|
||||
setTrips(prev => prev.filter(t => t.id !== id))
|
||||
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.archived'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.archiveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.unarchive(id)
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.restored'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.restoreError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpdate = (tripId, coverImage) => {
|
||||
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
|
||||
setTrips(prev => prev.map(update))
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||
|| trips[0]
|
||||
|| null
|
||||
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary)', ...font }}>
|
||||
<Navbar />
|
||||
{demoMode && <DemoBanner />}
|
||||
<div style={{ paddingTop: 56 }}>
|
||||
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{isLoading ? t('common.loading')
|
||||
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
|
||||
: t('dashboard.subtitle.empty')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
{/* Loading skeletons */}
|
||||
{isLoading && (
|
||||
<>
|
||||
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && trips.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '80px 20px' }}>
|
||||
<div style={{ width: 80, height: 80, background: '#f3f4f6', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Map size={36} style={{ color: '#d1d5db' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('dashboard.emptyTitle')}</h3>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
{t('dashboard.emptyText')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={16} /> {t('dashboard.emptyButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spotlight */}
|
||||
{!isLoading && spotlight && (
|
||||
<SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rest grid */}
|
||||
{!isLoading && rest.length > 0 && (
|
||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archived section */}
|
||||
{!isLoading && archivedTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #f3f4f6', paddingTop: 24 }}>
|
||||
<button
|
||||
onClick={() => setShowArchived(v => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: showArchived ? 16 : 0, fontFamily: 'inherit' }}
|
||||
>
|
||||
<Archive size={15} style={{ color: '#9ca3af' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280' }}>
|
||||
{t('dashboard.archived')} ({archivedTrips.length})
|
||||
</span>
|
||||
{showArchived ? <ChevronUp size={14} style={{ color: '#9ca3af' }} /> : <ChevronDown size={14} style={{ color: '#9ca3af' }} />}
|
||||
</button>
|
||||
{showArchived && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{archivedTrips.map(trip => (
|
||||
<ArchivedRow
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onUnarchive={handleUnarchive}
|
||||
onDelete={handleDelete}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats sidebar */}
|
||||
<div className="hidden lg:block" style={{ position: 'sticky', top: 80, flexShrink: 0 }}>
|
||||
<TravelStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TripFormModal
|
||||
isOpen={showForm}
|
||||
onClose={() => { setShowForm(false); setEditingTrip(null) }}
|
||||
onSave={editingTrip ? handleUpdate : handleCreate}
|
||||
trip={editingTrip}
|
||||
onCoverUpdate={handleCoverUpdate}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: 0.5 }
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: 0 }
|
||||
}
|
||||
.trip-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
@media(max-width: 1024px) { .trip-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media(max-width: 640px) { .trip-grid { grid-template-columns: 1fr; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { tripsApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function FilesPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const [trip, setTrip] = useState(null)
|
||||
const [places, setPlaces] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, placesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
placesApi.list(tripId),
|
||||
])
|
||||
setTrip(tripData.trip)
|
||||
setPlaces(placesData.places)
|
||||
await tripStore.loadFiles(tripId)
|
||||
} catch (err) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFiles(tripStore.files)
|
||||
}, [tripStore.files])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
await tripStore.addFile(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (fileId) => {
|
||||
await tripStore.deleteFile(tripId, fileId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
to={`/trips/${tripId}`}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zur Planung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dateien & Dokumente</h1>
|
||||
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileManager
|
||||
files={files}
|
||||
onUpload={handleUpload}
|
||||
onDelete={handleDelete}
|
||||
places={places}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t, language } = useTranslation()
|
||||
const [mode, setMode] = useState('login') // 'login' | 'register'
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [appConfig, setAppConfig] = useState(null)
|
||||
|
||||
const { login, register, demoLogin } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().catch(() => null).then(config => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await demoLogin()
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Demo-Login fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
||||
await register(username, email, password)
|
||||
} else {
|
||||
await login(email, password)
|
||||
}
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || t('login.error'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
|
||||
|
||||
const inputBase = {
|
||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||
|
||||
{/* Sprach-Toggle oben rechts */}
|
||||
<button
|
||||
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99,
|
||||
background: 'rgba(0,0,0,0.06)', border: 'none',
|
||||
fontSize: 13, fontWeight: 500, color: '#374151',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{language === 'en' ? 'DE' : 'EN'}
|
||||
</button>
|
||||
|
||||
{/* Left — branding */}
|
||||
<div style={{ display: 'none', width: '55%', background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px', position: 'relative', overflow: 'hidden' }}
|
||||
className="lg-panel">
|
||||
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
|
||||
|
||||
{/* Stars */}
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
{Array.from({ length: 40 }, (_, i) => (
|
||||
<div key={i} className="login-star" style={{
|
||||
position: 'absolute',
|
||||
width: Math.random() > 0.7 ? 2 : 1,
|
||||
height: Math.random() > 0.7 ? 2 : 1,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
opacity: 0.15 + Math.random() * 0.25,
|
||||
top: `${Math.random() * 70}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 4}s`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Animated glow orbs */}
|
||||
<div className="login-orb1" style={{ position: 'absolute', width: 500, height: 500, borderRadius: '50%', background: 'radial-gradient(circle, rgba(99,102,241,0.1) 0%, transparent 70%)', filter: 'blur(80px)' }} />
|
||||
<div className="login-orb2" style={{ position: 'absolute', width: 350, height: 350, borderRadius: '50%', background: 'radial-gradient(circle, rgba(14,165,233,0.08) 0%, transparent 70%)', filter: 'blur(60px)' }} />
|
||||
|
||||
{/* Animated planes — realistic silhouettes at different sizes/speeds */}
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||
{/* Plane 1 — large, slow, foreground */}
|
||||
<svg className="login-plane1" viewBox="0 0 480 120" style={{ position: 'absolute', width: 48, opacity: 0.12 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 2 — small, faster, higher */}
|
||||
<svg className="login-plane2" viewBox="0 0 480 120" style={{ position: 'absolute', width: 24, opacity: 0.08 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 3 — medium, mid-speed */}
|
||||
<svg className="login-plane3" viewBox="0 0 480 120" style={{ position: 'absolute', width: 32, opacity: 0.06 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-5)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 4 — tiny, fast, high */}
|
||||
<svg className="login-plane4" viewBox="0 0 480 120" style={{ position: 'absolute', width: 16, opacity: 0.07 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-10)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 5 — medium, right to left, lower */}
|
||||
<svg className="login-plane5" viewBox="0 0 480 120" style={{ position: 'absolute', width: 28, opacity: 0.05 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(8) scale(-1,1)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 6 — tiny distant */}
|
||||
<svg className="login-plane6" viewBox="0 0 480 120" style={{ position: 'absolute', width: 12, opacity: 0.04 }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-15)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}>
|
||||
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}>
|
||||
<Plane size={24} style={{ color: '#0f172a' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
</div>
|
||||
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}>
|
||||
{t('login.tagline')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
||||
{t('login.description')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||
{[
|
||||
{ Icon: Map, label: t('login.features.maps'), desc: t('login.features.mapsDesc') },
|
||||
{ Icon: Zap, label: t('login.features.realtime'), desc: t('login.features.realtimeDesc') },
|
||||
{ Icon: Wallet, label: t('login.features.budget'), desc: t('login.features.budgetDesc') },
|
||||
{ Icon: Users, label: t('login.features.collab'), desc: t('login.features.collabDesc') },
|
||||
{ Icon: CheckSquare, label: t('login.features.packing'), desc: t('login.features.packingDesc') },
|
||||
{ Icon: BookMarked, label: t('login.features.bookings'), desc: t('login.features.bookingsDesc') },
|
||||
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||
].map(({ Icon, label, desc }) => (
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
||||
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
||||
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ marginTop: 36, fontSize: 11.5, color: 'rgba(255,255,255,0.25)', letterSpacing: '0.03em' }}>
|
||||
{t('login.selfHosted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — form */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 24px', background: '#f9fafb' }}>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
|
||||
{/* Mobile logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
|
||||
className="mobile-logo">
|
||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Plane size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username (register only) */}
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text" value={username} onChange={e => setUsername(e.target.value)} required
|
||||
placeholder="admin" style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
placeholder={t('login.emailPlaceholder')} style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} required
|
||||
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
|
||||
}}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#111827'}
|
||||
>
|
||||
{isLoading
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle login/register */}
|
||||
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
|
||||
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
||||
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Demo login button */}
|
||||
{appConfig?.demo_mode && (
|
||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||
style={{
|
||||
marginTop: 16, width: '100%', padding: '14px',
|
||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
||||
color: '#451a03', border: 'none', borderRadius: 14,
|
||||
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||
>
|
||||
<Plane size={18} />
|
||||
Demo ausprobieren — ohne Registrierung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
@keyframes orbFloat1 {
|
||||
0%, 100% { top: 15%; left: 30%; }
|
||||
25% { top: 25%; left: 55%; }
|
||||
50% { top: 45%; left: 40%; }
|
||||
75% { top: 20%; left: 20%; }
|
||||
}
|
||||
@keyframes orbFloat2 {
|
||||
0%, 100% { bottom: 20%; right: 15%; }
|
||||
25% { bottom: 35%; right: 35%; }
|
||||
50% { bottom: 15%; right: 45%; }
|
||||
75% { bottom: 40%; right: 20%; }
|
||||
}
|
||||
.login-orb1 { animation: orbFloat1 20s ease-in-out infinite; }
|
||||
.login-orb2 { animation: orbFloat2 25s ease-in-out infinite; }
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.login-star { animation: twinkle 3s ease-in-out infinite; }
|
||||
|
||||
@keyframes plane1Move {
|
||||
0% { left: -8%; top: 30%; transform: rotate(-8deg); }
|
||||
100% { left: 108%; top: 10%; transform: rotate(-12deg); }
|
||||
}
|
||||
@keyframes plane2Move {
|
||||
0% { right: -5%; top: 18%; transform: rotate(5deg); }
|
||||
100% { right: 110%; top: 8%; transform: rotate(3deg); }
|
||||
}
|
||||
@keyframes plane3Move {
|
||||
0% { left: -6%; top: 55%; transform: rotate(-10deg); }
|
||||
100% { left: 110%; top: 35%; transform: rotate(-6deg); }
|
||||
}
|
||||
@keyframes plane4Move {
|
||||
0% { left: -4%; top: 8%; transform: rotate(-3deg); }
|
||||
100% { left: 110%; top: 5%; transform: rotate(-5deg); }
|
||||
}
|
||||
@keyframes plane5Move {
|
||||
0% { right: -6%; top: 65%; transform: rotate(3deg); }
|
||||
100% { right: 110%; top: 50%; transform: rotate(-2deg); }
|
||||
}
|
||||
@keyframes plane6Move {
|
||||
0% { left: -3%; top: 75%; transform: rotate(-7deg); }
|
||||
100% { left: 110%; top: 58%; transform: rotate(-5deg); }
|
||||
}
|
||||
.login-plane1 { animation: plane1Move 24s ease-in-out infinite; }
|
||||
.login-plane2 { animation: plane2Move 18s ease-in-out infinite; animation-delay: 6s; }
|
||||
.login-plane3 { animation: plane3Move 30s ease-in-out infinite; animation-delay: 12s; }
|
||||
.login-plane4 { animation: plane4Move 14s ease-in-out infinite; animation-delay: 3s; }
|
||||
.login-plane5 { animation: plane5Move 22s ease-in-out infinite; animation-delay: 9s; }
|
||||
.login-plane6 { animation: plane6Move 32s ease-in-out infinite; animation-delay: 16s; }
|
||||
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { tripsApi, daysApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import PhotoGallery from '../components/Photos/PhotoGallery'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function PhotosPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const [trip, setTrip] = useState(null)
|
||||
const [days, setDays] = useState([])
|
||||
const [places, setPlaces] = useState([])
|
||||
const [photos, setPhotos] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, daysData, placesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
])
|
||||
setTrip(tripData.trip)
|
||||
setDays(daysData.days)
|
||||
setPlaces(placesData.places)
|
||||
|
||||
// Load photos
|
||||
await tripStore.loadPhotos(tripId)
|
||||
} catch (err) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync photos from store
|
||||
useEffect(() => {
|
||||
setPhotos(tripStore.photos)
|
||||
}, [tripStore.photos])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
await tripStore.addPhoto(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId) => {
|
||||
await tripStore.deletePhoto(tripId, photoId)
|
||||
}
|
||||
|
||||
const handleUpdate = async (photoId, data) => {
|
||||
await tripStore.updatePhoto(tripId, photoId, data)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
to={`/trips/${tripId}`}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zur Planung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Fotos</h1>
|
||||
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PhotoGallery
|
||||
photos={photos}
|
||||
onUpload={handleUpload}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
places={places}
|
||||
days={days}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { register } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await register(username, email, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left panel */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 flex-col justify-center items-center p-12 text-white">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Map className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Jetzt starten</h1>
|
||||
<p className="text-slate-300 text-lg leading-relaxed">
|
||||
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-3 text-left">
|
||||
{[
|
||||
'✓ Unbegrenzte Reisepläne',
|
||||
'✓ Interaktive Kartenansicht',
|
||||
'✓ Orte und Kategorien verwalten',
|
||||
'✓ Reservierungen tracken',
|
||||
'✓ Packlisten erstellen',
|
||||
'✓ Fotos und Dateien speichern',
|
||||
].map(item => (
|
||||
<p key={item} className="text-slate-200 text-sm">{item}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="lg:hidden flex items-center gap-2 mb-8 justify-center">
|
||||
<Map className="w-8 h-8 text-slate-900" />
|
||||
<span className="text-2xl font-bold text-slate-900">NOMAD</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-1">Konto erstellen</h2>
|
||||
<p className="text-slate-500 mb-8">Beginnen Sie Ihre Reiseplanung</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="maxmustermann"
|
||||
minLength={3}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ihre@email.de"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Mind. 6 Zeichen"
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Passwort wiederholen"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 px-4 bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Registrieren...
|
||||
</>
|
||||
) : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
function Section({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuthStore()
|
||||
const avatarInputRef = React.useRef(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState({})
|
||||
|
||||
// Map settings
|
||||
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10)
|
||||
|
||||
// Display
|
||||
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
|
||||
|
||||
// Account
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
const saveMapSettings = async () => {
|
||||
setSaving(s => ({ ...s, map: true }))
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(defaultLat),
|
||||
default_lng: parseFloat(defaultLng),
|
||||
default_zoom: parseInt(defaultZoom),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, map: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const saveDisplay = async () => {
|
||||
setSaving(s => ({ ...s, display: true }))
|
||||
try {
|
||||
await updateSetting('temperature_unit', tempUnit)
|
||||
toast.success(t('settings.toast.displaySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, display: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(s => ({ ...s, profile: true }))
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, profile: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Map settings */}
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value=""
|
||||
onChange={value => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({
|
||||
value: p.url,
|
||||
label: p.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={e => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={e => setDefaultLat(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={e => setDefaultLng(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving.map}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving.map ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* Display */}
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Dark Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: false, label: t('settings.light'), icon: Sun },
|
||||
{ value: true, label: t('settings.dark'), icon: Moon },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.dark_mode === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.dark_mode === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprache */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeitformat */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('settings.uploadAvatar')}
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.removeAvatar')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving.profile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveProfile')}
|
||||
</button>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
|
||||
export default function TripPlannerPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const tripStore = useTripStore()
|
||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan') },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations') },
|
||||
{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') },
|
||||
{ id: 'finanzplan', label: t('trip.tabs.budget') },
|
||||
{ id: 'dateien', label: t('trip.tabs.files') },
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
setActiveTab(tabId)
|
||||
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
|
||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
|
||||
}
|
||||
const [leftWidth, setLeftWidth] = useState(() => parseInt(localStorage.getItem('sidebarLeftWidth')) || 340)
|
||||
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false)
|
||||
const isResizingLeft = useRef(false)
|
||||
const isResizingRight = useRef(false)
|
||||
|
||||
const [selectedPlaceId, setSelectedPlaceId] = useState(null)
|
||||
const [showPlaceForm, setShowPlaceForm] = useState(false)
|
||||
const [editingPlace, setEditingPlace] = useState(null)
|
||||
const [showTripForm, setShowTripForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [route, setRoute] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [fitKey, setFitKey] = useState(0)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
|
||||
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripStore.loadFiles(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripStore.loadReservations(tripId)
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket: join trip and listen for remote events
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = useTripStore.getState().handleRemoteEvent
|
||||
joinTrip(tripId)
|
||||
addListener(handler)
|
||||
return () => {
|
||||
leaveTrip(tripId)
|
||||
removeListener(handler)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e) => {
|
||||
if (isResizingLeft.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
|
||||
setLeftWidth(w)
|
||||
localStorage.setItem('sidebarLeftWidth', w)
|
||||
}
|
||||
if (isResizingRight.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
|
||||
setRightWidth(w)
|
||||
localStorage.setItem('sidebarRightWidth', w)
|
||||
}
|
||||
}
|
||||
const onUp = () => {
|
||||
isResizingLeft.current = false
|
||||
isResizingRight.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const mapPlaces = useCallback(() => {
|
||||
return places.filter(p => p.lat && p.lng)
|
||||
}, [places])
|
||||
|
||||
const handleSelectDay = useCallback((dayId) => {
|
||||
tripStore.setSelectedDay(dayId)
|
||||
setRouteInfo(null)
|
||||
setFitKey(k => k + 1)
|
||||
setMobileSidebarOpen(null)
|
||||
|
||||
// Auto-show Luftlinien for the selected day
|
||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
if (waypoints.length >= 2) {
|
||||
setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||
} else {
|
||||
setRoute(null)
|
||||
}
|
||||
}, [tripStore])
|
||||
|
||||
const handlePlaceClick = useCallback((placeId) => {
|
||||
setSelectedPlaceId(placeId)
|
||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((placeId) => {
|
||||
const opening = placeId !== undefined
|
||||
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
|
||||
if (opening) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [])
|
||||
|
||||
const handleMapClick = useCallback(() => {
|
||||
setSelectedPlaceId(null)
|
||||
}, [])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
if (editingPlace) {
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
} else {
|
||||
await tripStore.addPlace(tripId, data)
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
}
|
||||
}, [editingPlace, tripId, tripStore, toast])
|
||||
|
||||
const handleDeletePlace = useCallback(async (placeId) => {
|
||||
if (!confirm(t('trip.confirm.deletePlace'))) return
|
||||
try {
|
||||
await tripStore.deletePlace(tripId, placeId)
|
||||
if (selectedPlaceId === placeId) setSelectedPlaceId(null)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast, selectedPlaceId])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
try {
|
||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}, [selectedDayId, tripId, tripStore, toast])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripStore.updateDayTitle(tripId, dayId, title) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
return r
|
||||
} else {
|
||||
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowReservationModal(false)
|
||||
return r
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
const dayOrderMap = useMemo(() => {
|
||||
if (!selectedDayId) return {}
|
||||
const da = assignments[String(selectedDayId)] || []
|
||||
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
||||
const map = {}
|
||||
sorted.forEach((a, i) => { if (a.place?.id) map[a.place.id] = i + 1 })
|
||||
return map
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
||||
const defaultZoom = settings.default_zoom || 10
|
||||
|
||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, border: '3px solid rgba(0,0,0,0.1)', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!trip) return null
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
|
||||
<div style={{
|
||||
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
height: 44,
|
||||
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRIP_TABS.map(tab => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: isActive ? 600 : 400,
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontFamily: 'inherit', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
|
||||
>{tab.shortLabel
|
||||
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
|
||||
: tab.label
|
||||
}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Offset by navbar (56px) + tab bar (44px) */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', marginTop: 100, position: 'relative' }}>
|
||||
|
||||
{activeTab === 'plan' && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
places={mapPlaces()}
|
||||
route={route}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
center={defaultCenter}
|
||||
zoom={defaultZoom}
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
/>
|
||||
|
||||
{routeInfo && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
|
||||
borderRadius: 99, padding: '6px 20px', zIndex: 30,
|
||||
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
|
||||
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
|
||||
}}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: '#d1d5db' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
style={{
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: leftCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: leftCollapsed ? 0 : leftWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: leftCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onSelectDay={handleSelectDay}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onReorder={handleReorder}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
reservations={reservations}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setRightCollapsed(c => !c)}
|
||||
style={{
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: rightCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: rightCollapsed ? 0 : rightWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: rightCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: rightCollapsed ? 0 : 1,
|
||||
}}>
|
||||
{!rightCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
<PlacesSidebar
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 12, left: 12, right: 12, justifyContent: 'space-between', zIndex: 30 }}>
|
||||
<button onClick={() => setMobileSidebarOpen('left')}
|
||||
style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
|
||||
{t('trip.mobilePlan')}
|
||||
</button>
|
||||
<button onClick={() => setMobileSidebarOpen('right')}
|
||||
style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
|
||||
{t('trip.mobilePlaces')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedPlace && (
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
assignments={assignments}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mobileSidebarOpen && (
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 50 }} onClick={() => setMobileSidebarOpen(null)}>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', maxHeight: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
|
||||
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
|
||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'buchungen' && (
|
||||
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'packliste' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'dateien' && (
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<FileManager
|
||||
files={files || []}
|
||||
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
onDelete={(id) => tripStore.deleteFile(tripId, id)}
|
||||
onUpdate={null}
|
||||
places={places}
|
||||
reservations={reservations}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi } from '../api/client'
|
||||
import { connect, disconnect } from '../api/websocket'
|
||||
|
||||
export const useAuthStore = create((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('auth_token') || null,
|
||||
isAuthenticated: !!localStorage.getItem('auth_token'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password })
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username, email, password) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.register({ username, email, password })
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
const token = get().token
|
||||
if (!token) {
|
||||
set({ isLoading: false })
|
||||
return
|
||||
}
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const data = await authApi.me()
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
connect(token)
|
||||
} catch (err) {
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updateMapsKey: async (key) => {
|
||||
try {
|
||||
await authApi.updateMapsKey(key)
|
||||
set(state => ({
|
||||
user: { ...state.user, maps_api_key: key || null }
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels')
|
||||
}
|
||||
},
|
||||
|
||||
updateApiKeys: async (keys) => {
|
||||
try {
|
||||
const data = await authApi.updateApiKeys(keys)
|
||||
set({ user: data.user })
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der API-Schlüssel')
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileData) => {
|
||||
try {
|
||||
const data = await authApi.updateSettings(profileData)
|
||||
set({ user: data.user })
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Profils')
|
||||
}
|
||||
},
|
||||
|
||||
uploadAvatar: async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
const data = await authApi.uploadAvatar(formData)
|
||||
set(state => ({ user: { ...state.user, avatar_url: data.avatar_url } }))
|
||||
return data
|
||||
},
|
||||
|
||||
deleteAvatar: async () => {
|
||||
await authApi.deleteAvatar()
|
||||
set(state => ({ user: { ...state.user, avatar_url: null } }))
|
||||
},
|
||||
|
||||
setDemoMode: (val) => {
|
||||
if (val) localStorage.setItem('demo_mode', 'true')
|
||||
else localStorage.removeItem('demo_mode')
|
||||
set({ demoMode: val })
|
||||
},
|
||||
|
||||
demoLogin: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.demoLogin()
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Demo-Login fehlgeschlagen'
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/client'
|
||||
|
||||
export const useSettingsStore = create((set, get) => ({
|
||||
settings: {
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
dark_mode: false,
|
||||
default_currency: 'USD',
|
||||
language: localStorage.getItem('app_language') || 'en',
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
},
|
||||
isLoaded: false,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const data = await settingsApi.get()
|
||||
set(state => ({
|
||||
settings: { ...state.settings, ...data.settings },
|
||||
isLoaded: true,
|
||||
}))
|
||||
} catch (err) {
|
||||
set({ isLoaded: true })
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: async (key, value) => {
|
||||
set(state => ({
|
||||
settings: { ...state.settings, [key]: value }
|
||||
}))
|
||||
if (key === 'language') localStorage.setItem('app_language', value)
|
||||
try {
|
||||
await settingsApi.set(key, value)
|
||||
} catch (err) {
|
||||
console.error('Failed to save setting:', err)
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellung')
|
||||
}
|
||||
},
|
||||
|
||||
setLanguageLocal: (lang) => {
|
||||
localStorage.setItem('app_language', lang)
|
||||
set(state => ({ settings: { ...state.settings, language: lang } }))
|
||||
},
|
||||
|
||||
updateSettings: async (settingsObj) => {
|
||||
set(state => ({
|
||||
settings: { ...state.settings, ...settingsObj }
|
||||
}))
|
||||
try {
|
||||
await settingsApi.setBulk(settingsObj)
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err)
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellungen')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,789 @@
|
||||
import { create } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, assignmentsApi, packingApi, tagsApi, categoriesApi, budgetApi, filesApi, reservationsApi, dayNotesApi } from '../api/client'
|
||||
|
||||
export const useTripStore = create((set, get) => ({
|
||||
trip: null,
|
||||
days: [],
|
||||
places: [],
|
||||
assignments: {}, // { [dayId]: [assignment objects] }
|
||||
dayNotes: {}, // { [dayId]: [note objects] }
|
||||
packingItems: [],
|
||||
tags: [],
|
||||
categories: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
reservations: [],
|
||||
selectedDayId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
|
||||
|
||||
// Handle remote WebSocket events without making API calls
|
||||
handleRemoteEvent: (event) => {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
switch (type) {
|
||||
// Places
|
||||
case 'place:created':
|
||||
if (state.places.some(p => p.id === payload.place.id)) return {}
|
||||
return { places: [payload.place, ...state.places] }
|
||||
case 'place:updated':
|
||||
return {
|
||||
places: state.places.map(p => p.id === payload.place.id ? payload.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === payload.place.id ? { ...a, place: payload.place } : a)
|
||||
])
|
||||
),
|
||||
}
|
||||
case 'place:deleted':
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String(payload.assignment.day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
// Skip if already present (by id OR by place_id to handle optimistic temp ids)
|
||||
const placeId = payload.assignment.place?.id || payload.assignment.place_id
|
||||
if (existing.some(a => a.id === payload.assignment.id || (placeId && a.place?.id === placeId))) {
|
||||
// Replace temp entry with server version if needed
|
||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment : a),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds = payload.orderedIds || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days
|
||||
case 'day:created':
|
||||
if (state.days.some(d => d.id === payload.day.id)) return {}
|
||||
return { days: [...state.days, payload.day] }
|
||||
case 'day:updated':
|
||||
return {
|
||||
days: state.days.map(d => d.id === payload.day.id ? payload.day : d),
|
||||
}
|
||||
case 'day:deleted': {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === payload.note.id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:updated': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === payload.note.id ? payload.note : n),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packing
|
||||
case 'packing:created':
|
||||
if (state.packingItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item] }
|
||||
case 'packing:updated':
|
||||
return {
|
||||
packingItems: state.packingItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'packing:deleted':
|
||||
return {
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Budget
|
||||
case 'budget:created':
|
||||
if (state.budgetItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item] }
|
||||
case 'budget:updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'budget:deleted':
|
||||
return {
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Reservations
|
||||
case 'reservation:created':
|
||||
if (state.reservations.some(r => r.id === payload.reservation.id)) return {}
|
||||
return { reservations: [payload.reservation, ...state.reservations] }
|
||||
case 'reservation:updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === payload.reservation.id ? payload.reservation : r),
|
||||
}
|
||||
case 'reservation:deleted':
|
||||
return {
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}
|
||||
|
||||
// Trip
|
||||
case 'trip:updated':
|
||||
return { trip: payload.trip }
|
||||
|
||||
// Files
|
||||
case 'file:created':
|
||||
if (state.files.some(f => f.id === payload.file.id)) return {}
|
||||
return { files: [payload.file, ...state.files] }
|
||||
case 'file:updated':
|
||||
return {
|
||||
files: state.files.map(f => f.id === payload.file.id ? payload.file : f),
|
||||
}
|
||||
case 'file:deleted':
|
||||
return {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Load everything for a trip
|
||||
loadTrip: async (tripId) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
|
||||
set({
|
||||
trip: tripData.trip,
|
||||
days: daysData.days,
|
||||
places: placesData.places,
|
||||
assignments: assignmentsMap,
|
||||
dayNotes: dayNotesMap,
|
||||
packingItems: packingData.items,
|
||||
tags: tagsData.tags,
|
||||
categories: categoriesData.categories,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err.message })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Ortes')
|
||||
}
|
||||
},
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
set(state => ({
|
||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
return data.place
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Ortes')
|
||||
}
|
||||
},
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
set(state => ({
|
||||
places: state.places.filter(p => p.id !== placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== placeId)
|
||||
])
|
||||
),
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Ortes')
|
||||
}
|
||||
},
|
||||
|
||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||
const state = get()
|
||||
const place = state.places.find(p => p.id === parseInt(placeId))
|
||||
if (!place) return
|
||||
|
||||
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
|
||||
if (existing) return
|
||||
|
||||
const tempId = Date.now() * -1
|
||||
const current = [...(state.assignments[String(dayId)] || [])]
|
||||
const insertIdx = position != null ? position : current.length
|
||||
const tempAssignment = {
|
||||
id: tempId,
|
||||
day_id: parseInt(dayId),
|
||||
order_index: insertIdx,
|
||||
notes: null,
|
||||
place,
|
||||
}
|
||||
|
||||
current.splice(insertIdx, 0, tempAssignment)
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: current,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||
const newAssignment = position != null
|
||||
? { ...data.assignment, order_index: insertIdx }
|
||||
: data.assignment
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].map(
|
||||
a => a.id === tempId ? newAssignment : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
// Reihenfolge am Server aktualisieren und lokalen State mit Server-Antwort synchronisieren
|
||||
if (position != null) {
|
||||
const updated = get().assignments[String(dayId)] || []
|
||||
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
|
||||
if (orderedIds.length > 0) {
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
// Lokalen State auf die gesendete Reihenfolge setzen
|
||||
set(state => {
|
||||
const items = state.assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = items.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return data.assignment
|
||||
} catch (err) {
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Zuweisen des Ortes')
|
||||
}
|
||||
},
|
||||
|
||||
removeAssignment: async (tripId, dayId, assignmentId) => {
|
||||
const prevAssignments = get().assignments
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Entfernen der Zuweisung')
|
||||
}
|
||||
},
|
||||
|
||||
reorderAssignments: async (tripId, dayId, orderedIds) => {
|
||||
const prevAssignments = get().assignments
|
||||
const dayItems = get().assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = dayItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Neuanordnen')
|
||||
}
|
||||
},
|
||||
|
||||
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
|
||||
const state = get()
|
||||
const prevAssignments = state.assignments
|
||||
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
|
||||
if (!assignment) return
|
||||
|
||||
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
|
||||
|
||||
// Build new order for target day with item inserted at correct position
|
||||
const newToItems = [...toItems]
|
||||
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(toDayId) })
|
||||
newToItems.forEach((a, i) => { a.order_index = i })
|
||||
|
||||
set(s => ({
|
||||
assignments: {
|
||||
...s.assignments,
|
||||
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
|
||||
[String(toDayId)]: newToItems,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
|
||||
if (newToItems.length > 1) {
|
||||
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
|
||||
}
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Zuweisung')
|
||||
}
|
||||
},
|
||||
|
||||
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
|
||||
const state = get()
|
||||
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, fromDayId, noteId)
|
||||
const result = await dayNotesApi.create(tripId, toDayId, {
|
||||
text: note.text, time: note.time, icon: note.icon, sort_order,
|
||||
})
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
|
||||
}
|
||||
}))
|
||||
} catch (err) {
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
|
||||
}
|
||||
}))
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Notiz')
|
||||
}
|
||||
},
|
||||
|
||||
setAssignments: (assignments) => {
|
||||
set({ assignments })
|
||||
},
|
||||
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Artikels')
|
||||
}
|
||||
},
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Artikels')
|
||||
}
|
||||
},
|
||||
|
||||
deletePackingItem: async (tripId, id) => {
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
} catch (err) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Artikels')
|
||||
}
|
||||
},
|
||||
|
||||
togglePackingItem: async (tripId, id, checked) => {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
} catch (err) {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
|
||||
)
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
updateDayNotes: async (tripId, dayId, notes) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { notes })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notizen')
|
||||
}
|
||||
},
|
||||
|
||||
updateDayTitle: async (tripId, dayId, title) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { title })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Tagesnamens')
|
||||
}
|
||||
},
|
||||
|
||||
addTag: async (data) => {
|
||||
try {
|
||||
const result = await tagsApi.create(data)
|
||||
set(state => ({ tags: [...state.tags, result.tag] }))
|
||||
return result.tag
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen des Tags')
|
||||
}
|
||||
},
|
||||
|
||||
addCategory: async (data) => {
|
||||
try {
|
||||
const result = await categoriesApi.create(data)
|
||||
set(state => ({ categories: [...state.categories, result.category] }))
|
||||
return result.category
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Kategorie')
|
||||
}
|
||||
},
|
||||
|
||||
updateTrip: async (tripId, data) => {
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
return result.trip
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reise')
|
||||
}
|
||||
},
|
||||
|
||||
loadBudgetItems: async (tripId) => {
|
||||
try {
|
||||
const data = await budgetApi.list(tripId)
|
||||
set({ budgetItems: data.items })
|
||||
} catch (err) {
|
||||
console.error('Failed to load budget items:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Budget-Eintrags')
|
||||
}
|
||||
},
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Budget-Eintrags')
|
||||
}
|
||||
},
|
||||
|
||||
deleteBudgetItem: async (tripId, id) => {
|
||||
const prev = get().budgetItems
|
||||
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await budgetApi.delete(tripId, id)
|
||||
} catch (err) {
|
||||
set({ budgetItems: prev })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Budget-Eintrags')
|
||||
}
|
||||
},
|
||||
|
||||
loadFiles: async (tripId) => {
|
||||
try {
|
||||
const data = await filesApi.list(tripId)
|
||||
set({ files: data.files })
|
||||
} catch (err) {
|
||||
console.error('Failed to load files:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addFile: async (tripId, formData) => {
|
||||
try {
|
||||
const data = await filesApi.upload(tripId, formData)
|
||||
set(state => ({ files: [data.file, ...state.files] }))
|
||||
return data.file
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Hochladen der Datei')
|
||||
}
|
||||
},
|
||||
|
||||
deleteFile: async (tripId, id) => {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Datei')
|
||||
}
|
||||
},
|
||||
|
||||
loadReservations: async (tripId) => {
|
||||
try {
|
||||
const data = await reservationsApi.list(tripId)
|
||||
set({ reservations: data.reservations })
|
||||
} catch (err) {
|
||||
console.error('Failed to load reservations:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addReservation: async (tripId, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.create(tripId, data)
|
||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||
return result.reservation
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Reservierung')
|
||||
}
|
||||
},
|
||||
|
||||
updateReservation: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
||||
}))
|
||||
return result.reservation
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reservierung')
|
||||
}
|
||||
},
|
||||
|
||||
toggleReservationStatus: async (tripId, id) => {
|
||||
const prev = get().reservations
|
||||
const current = prev.find(r => r.id === id)
|
||||
if (!current) return
|
||||
const newStatus = current.status === 'confirmed' ? 'pending' : 'confirmed'
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||
}))
|
||||
try {
|
||||
await reservationsApi.update(tripId, id, { status: newStatus })
|
||||
} catch {
|
||||
set({ reservations: prev })
|
||||
}
|
||||
},
|
||||
|
||||
deleteReservation: async (tripId, id) => {
|
||||
try {
|
||||
await reservationsApi.delete(tripId, id)
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Reservierung')
|
||||
}
|
||||
},
|
||||
|
||||
addDayNote: async (tripId, dayId, data) => {
|
||||
try {
|
||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), result.note],
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen der Notiz')
|
||||
}
|
||||
},
|
||||
|
||||
updateDayNote: async (tripId, dayId, id, data) => {
|
||||
try {
|
||||
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notiz')
|
||||
}
|
||||
},
|
||||
|
||||
deleteDayNote: async (tripId, dayId, id) => {
|
||||
const prev = get().dayNotes
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
|
||||
}
|
||||
}))
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, dayId, id)
|
||||
} catch (err) {
|
||||
set({ dayNotes: prev })
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Notiz')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
950: '#1e1b4b',
|
||||
},
|
||||
planner: {
|
||||
day: '#f8fafc',
|
||||
dayBorder: '#e2e8f0',
|
||||
dayHeader: '#1e293b',
|
||||
sidebar: '#ffffff',
|
||||
sidebarBorder: '#f1f5f9',
|
||||
overlay: 'rgba(15, 23, 42, 0.4)',
|
||||
dragActive: '#eef2ff',
|
||||
dragOver: '#c7d2fe',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
'day-column': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'place-card': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
'drag-overlay': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/nomad:latest
|
||||
container_name: nomad
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
|
||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
-256
@@ -1,256 +0,0 @@
|
||||
apiVersion: v1
|
||||
entries:
|
||||
trek:
|
||||
- apiVersion: v2
|
||||
appVersion: 3.1.1
|
||||
created: "2026-06-18T18:14:21.988472671Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 1308d3403aca1592d7f7f439159582a06f755a03a64691ce648394314af7c370
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.1.1.tgz
|
||||
version: 3.1.1
|
||||
- apiVersion: v2
|
||||
appVersion: 3.1.0
|
||||
created: "2026-06-16T20:23:47.236173686Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: fe609303edd165e95e6f4159128d64e125d017739fa4d63203620dcb23880165
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.1.0.tgz
|
||||
version: 3.1.0
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.22
|
||||
created: "2026-05-24T23:14:01.083827222Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: b8f8bc7eb7cb748af54085e6cffa6ee6f3469bbca418d00015d8accf85332d9a
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.22.tgz
|
||||
version: 3.0.22
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.21
|
||||
created: "2026-05-15T22:53:27.080448187Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: b5b6669db71f27f4eaede44b88a6b31c0daea917dbf94cedeb88fc324ee19d3a
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.21.tgz
|
||||
version: 3.0.21
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.20
|
||||
created: "2026-05-13T08:35:48.5574809Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 738daac35241850a4002db0e9890fd3df767727f623c2ee47e1f139e84071257
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.20.tgz
|
||||
version: 3.0.20
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.19
|
||||
created: "2026-05-13T08:13:52.33471774Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 8d082ff09f32af6f4f7ccc4272b00ac6cc403daaf5ae983d4003a5cf6f8d142e
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.19.tgz
|
||||
version: 3.0.19
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.18
|
||||
created: "2026-05-10T14:03:41.248252922Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 989ece82e9e27c697e946134006198c8e0f1126a823748a3e7dc34b6c6def52f
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.18.tgz
|
||||
version: 3.0.18
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.17
|
||||
created: "2026-05-07T11:50:07.700377093Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: e5882548f05658ad00dca89bce7d4a5ce6fa0cd792b0a8496bc2a61e48483221
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.17.tgz
|
||||
version: 3.0.17
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.16
|
||||
created: "2026-05-06T19:39:24.494762281Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 7b75e83a09c9d146e2bde13456ad77874c8bc0914afb4c64decd1e6b47b7bc00
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.16.tgz
|
||||
version: 3.0.16
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.15
|
||||
created: "2026-05-04T12:22:39.144120641Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: e1ca7f81f9609c18fefc4df011b88e8d238ff0335d5b91304fb55317d662c1a4
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.15.tgz
|
||||
version: 3.0.15
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.14
|
||||
created: "2026-05-03T15:40:22.910415864Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 254b21ed1c4d7e8ed67556798a39ec467390121402709a5500344486dcf0f3c7
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.14.tgz
|
||||
version: 3.0.14
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.13
|
||||
created: "2026-04-30T23:44:12.550076271Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 12db9a32ccb0da03ca546c37fc2214be3aef86bdef9e97f84d14cce6b917e52c
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.13.tgz
|
||||
version: 3.0.13
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.12
|
||||
created: "2026-04-28T22:17:31.376087591Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 5be7794a4a97fd9c68d7ac3ca61dc3eecd4ad64e11a0464ae55b1a5b1c71f178
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.12.tgz
|
||||
version: 3.0.12
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.11
|
||||
created: "2026-04-28T03:17:50.499976287Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 3e0ff316d0e1d677c09188ca86aad751bcb3434850c8a06d3bae7657d4f3da72
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.11.tgz
|
||||
version: 3.0.11
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.10
|
||||
created: "2026-04-26T22:22:47.282397115Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: db37f8e0e97bc60034c07b3c1dd4183aa70c24977faed6d7f6ae663337249bb9
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.10.tgz
|
||||
version: 3.0.10
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.9
|
||||
created: "2026-04-26T19:59:48.864913585Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 248b141e4d7537bba45d565f8b391245cf01009f226feaac3bc79b542b7ff942
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.9.tgz
|
||||
version: 3.0.9
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.8
|
||||
created: "2026-04-23T17:50:07.63169343Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 1c29c7a1fc6f8186087d081bd67bdc242925d189dd7ea7733eab50cd692a3c46
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.8.tgz
|
||||
version: 3.0.8
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.7
|
||||
created: "2026-04-23T09:08:03.459110835Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 1b0982d797cda5b2b8d44f99efc0085dd65ca2867d1c5278e51b5eb58aa75f97
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.7.tgz
|
||||
version: 3.0.7
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.6
|
||||
created: "2026-04-23T08:54:03.12910997Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 782fab1d93422ac5a59fbafa85b249bf133aa759ae5f5df2a52d2af27c707384
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.6.tgz
|
||||
version: 3.0.6
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.5
|
||||
created: "2026-04-23T08:07:35.852416199Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 28afc1dcd010d98e109d68b8d973d863a8987ad587a63afe7608277c3ff59d4b
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.5.tgz
|
||||
version: 3.0.5
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.4
|
||||
created: "2026-04-23T07:14:04.008427129Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 008a508c1a65b8f26547894cd8d11685246b1f293208a836e6637d56b4e4f5c5
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.4.tgz
|
||||
version: 3.0.4
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.3
|
||||
created: "2026-04-22T20:17:06.34222863Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 850e1ae9ddfa28c4acb78971b214477c99f0d8ed7ff46837738a103c2d4be22a
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.3.tgz
|
||||
version: 3.0.3
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.2
|
||||
created: "2026-04-22T19:25:56.169907974Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 8e58789715ff2dddcb78b78a9f025a9b8201f4dda64f490028752482b7bbb724
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.2.tgz
|
||||
version: 3.0.2
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.1
|
||||
created: "2026-04-22T17:58:37.730969833Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 227a515e07c084b7f100da209fdb026677960b05e1b43679594ea86b056d11a1
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.1.tgz
|
||||
version: 3.0.1
|
||||
- apiVersion: v2
|
||||
appVersion: 3.0.0
|
||||
created: "2026-04-22T16:59:27.126209546Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 006122243b712bd93d54a1b27223244514715c2872ba82f3735b635eb47d9805
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-3.0.0.tgz
|
||||
version: 3.0.0
|
||||
- apiVersion: v2
|
||||
appVersion: 2.9.14
|
||||
created: "2026-04-15T05:33:58.018378711Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 3af5d8cc8d144295dd222d03534f309fa46d6eebe097c48d6c5c339dcd4318fe
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-2.9.14.tgz
|
||||
version: 2.9.14
|
||||
- apiVersion: v2
|
||||
appVersion: 2.9.13
|
||||
created: "2026-04-11T13:26:57.619853141Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: d01e3761cf76ec3b3ec7278780631a33b86d50bc94d03642e5612f04b3c2b84b
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-2.9.13.tgz
|
||||
version: 2.9.13
|
||||
- apiVersion: v2
|
||||
appVersion: 2.9.12
|
||||
created: "2026-04-10T05:51:35.975580236Z"
|
||||
description: Minimal Helm chart for TREK app
|
||||
digest: 389603340e43a973eaf97cb5e5c74ebb95a26509185bbea82e3686a83d135ebf
|
||||
name: trek
|
||||
urls:
|
||||
- https://mauriceboe.github.io/TREK/trek-2.9.12.tgz
|
||||
version: 2.9.12
|
||||
generated: "2026-06-18T18:14:21.987876862Z"
|
||||
@@ -0,0 +1,3 @@
|
||||
PORT=3001
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
NODE_ENV=development
|
||||
Generated
+2155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "nomad-server",
|
||||
"version": "2.3.5",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node --experimental-sqlite src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dbPath = path.join(__dirname, 'data/travel.db');
|
||||
const db = new DatabaseSync(dbPath);
|
||||
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
|
||||
.run(hash, 'admin', 'admin@admin.com');
|
||||
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
|
||||
} else {
|
||||
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
||||
.run('admin', 'admin@admin.com', hash, 'admin');
|
||||
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
|
||||
}
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,28 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
// Try to read a persisted secret from disk
|
||||
const dataDir = path.resolve(__dirname, '../data');
|
||||
const secretFile = path.join(dataDir, '.jwt_secret');
|
||||
|
||||
try {
|
||||
JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim();
|
||||
} catch {
|
||||
// File doesn't exist yet — generate and persist a new secret
|
||||
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 });
|
||||
console.log('Generated and saved JWT secret to', secretFile);
|
||||
} catch (writeErr) {
|
||||
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr.message);
|
||||
console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { JWT_SECRET };
|
||||
@@ -0,0 +1,392 @@
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dataDir = path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'travel.db');
|
||||
|
||||
let _db = null;
|
||||
|
||||
function initDb() {
|
||||
if (_db) {
|
||||
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
try { _db.close(); } catch (e) {}
|
||||
_db = null;
|
||||
}
|
||||
|
||||
_db = new DatabaseSync(dbPath);
|
||||
_db.exec('PRAGMA journal_mode = WAL');
|
||||
_db.exec('PRAGMA busy_timeout = 5000');
|
||||
_db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create all tables
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
maps_api_key TEXT,
|
||||
unsplash_api_key TEXT,
|
||||
openweather_api_key TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
UNIQUE(user_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
currency TEXT DEFAULT 'EUR',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_number INTEGER NOT NULL,
|
||||
date TEXT,
|
||||
notes TEXT,
|
||||
UNIQUE(trip_id, day_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
icon TEXT DEFAULT '📍',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#10b981',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
address TEXT,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
price REAL,
|
||||
currency TEXT,
|
||||
reservation_status TEXT DEFAULT 'none',
|
||||
reservation_notes TEXT,
|
||||
reservation_datetime TEXT,
|
||||
place_time TEXT,
|
||||
duration_minutes INTEGER DEFAULT 60,
|
||||
notes TEXT,
|
||||
image_url TEXT,
|
||||
google_place_id TEXT,
|
||||
website TEXT,
|
||||
phone TEXT,
|
||||
transport_mode TEXT DEFAULT 'walking',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_tags (
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (place_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packing_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
checked INTEGER DEFAULT 0,
|
||||
category TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
caption TEXT,
|
||||
taken_at TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
reservation_time TEXT,
|
||||
location TEXT,
|
||||
confirmation_number TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
invited_by INTEGER REFERENCES users(id),
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
time TEXT,
|
||||
icon TEXT DEFAULT '📝',
|
||||
sort_order REAL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
name TEXT NOT NULL,
|
||||
total_price REAL NOT NULL DEFAULT 0,
|
||||
persons INTEGER DEFAULT NULL,
|
||||
days INTEGER DEFAULT NULL,
|
||||
note TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
_db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
`);
|
||||
|
||||
// Migrations
|
||||
const migrations = [
|
||||
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`,
|
||||
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`,
|
||||
`ALTER TABLE places ADD COLUMN notes TEXT`,
|
||||
`ALTER TABLE places ADD COLUMN image_url TEXT`,
|
||||
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`,
|
||||
`ALTER TABLE days ADD COLUMN title TEXT`,
|
||||
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`,
|
||||
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
|
||||
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`,
|
||||
`ALTER TABLE trips ADD COLUMN cover_image TEXT`,
|
||||
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`,
|
||||
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
||||
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
|
||||
`ALTER TABLE users ADD COLUMN avatar TEXT`,
|
||||
];
|
||||
|
||||
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
|
||||
try {
|
||||
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get()
|
||||
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) {
|
||||
_db.exec(`
|
||||
CREATE TABLE budget_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
name TEXT NOT NULL,
|
||||
total_price REAL NOT NULL DEFAULT 0,
|
||||
persons INTEGER DEFAULT NULL,
|
||||
days INTEGER DEFAULT NULL,
|
||||
note TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO budget_items_new SELECT * FROM budget_items;
|
||||
DROP TABLE budget_items;
|
||||
ALTER TABLE budget_items_new RENAME TO budget_items;
|
||||
`)
|
||||
}
|
||||
} catch (e) { /* table doesn't exist yet or already migrated */ }
|
||||
for (const sql of migrations) {
|
||||
try { _db.exec(sql); } catch (e) { /* column already exists */ }
|
||||
}
|
||||
|
||||
// First registered user becomes admin — no default admin seed needed
|
||||
|
||||
// Seed: default categories
|
||||
try {
|
||||
const existingCats = _db.prepare('SELECT COUNT(*) as count FROM categories').get();
|
||||
if (existingCats.count === 0) {
|
||||
const defaultCategories = [
|
||||
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
||||
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
||||
{ name: 'Sehenswürdigkeit', color: '#8b5cf6', icon: '🏛️' },
|
||||
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
||||
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
||||
{ name: 'Aktivität', color: '#10b981', icon: '🎯' },
|
||||
{ name: 'Bar/Café', color: '#f97316', icon: '☕' },
|
||||
{ name: 'Strand', color: '#06b6d4', icon: '🏖️' },
|
||||
{ name: 'Natur', color: '#84cc16', icon: '🌿' },
|
||||
{ name: 'Sonstiges', color: '#6366f1', icon: '📍' },
|
||||
];
|
||||
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
|
||||
console.log('Default categories seeded');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error seeding categories:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initDb();
|
||||
|
||||
// Demo mode: seed admin + demo user + example trips
|
||||
if (process.env.DEMO_MODE === 'true') {
|
||||
try {
|
||||
const { seedDemoData } = require('../demo/demo-seed');
|
||||
seedDemoData(_db);
|
||||
} catch (err) {
|
||||
console.error('[Demo] Seed error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy so all route modules always use the current _db instance
|
||||
// without needing a server restart after reinitialize()
|
||||
const db = new Proxy({}, {
|
||||
get(_, prop) {
|
||||
const val = _db[prop];
|
||||
return typeof val === 'function' ? val.bind(_db) : val;
|
||||
},
|
||||
set(_, prop, val) {
|
||||
_db[prop] = val;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
function closeDb() {
|
||||
if (_db) {
|
||||
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
try { _db.close(); } catch (e) {}
|
||||
_db = null;
|
||||
console.log('[DB] Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
function reinitialize() {
|
||||
console.log('[DB] Reinitializing database connection after restore...');
|
||||
// initDb handles close + reopen, but if closeDb was already called, _db is null
|
||||
if (_db) closeDb();
|
||||
initDb();
|
||||
console.log('[DB] Database reinitialized successfully');
|
||||
}
|
||||
|
||||
function getPlaceWithTags(placeId) {
|
||||
const place = _db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
|
||||
if (!place) return null;
|
||||
|
||||
const tags = _db.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(placeId);
|
||||
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id ? {
|
||||
id: place.category_id,
|
||||
name: place.category_name,
|
||||
color: place.category_color,
|
||||
icon: place.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function canAccessTrip(tripId, userId) {
|
||||
return _db.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId);
|
||||
}
|
||||
|
||||
function isOwner(tripId, userId) {
|
||||
return !!_db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||
}
|
||||
|
||||
module.exports = { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
||||
@@ -0,0 +1,84 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dataDir = path.join(__dirname, '../../data');
|
||||
const dbPath = path.join(dataDir, 'travel.db');
|
||||
const baselinePath = path.join(dataDir, 'travel-baseline.db');
|
||||
|
||||
function resetDemoUser() {
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { db, closeDb, reinitialize } = require('../db/database');
|
||||
|
||||
// Save admin's current credentials and API keys (these should survive the reset)
|
||||
const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
|
||||
let adminData = null;
|
||||
try {
|
||||
adminData = db.prepare(
|
||||
'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?'
|
||||
).get(adminEmail);
|
||||
} catch (e) {
|
||||
console.error('[Demo Reset] Failed to read admin data:', e.message);
|
||||
}
|
||||
|
||||
// Flush WAL to main DB file
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
|
||||
// Close DB connection
|
||||
closeDb();
|
||||
|
||||
// Restore baseline
|
||||
try {
|
||||
fs.copyFileSync(baselinePath, dbPath);
|
||||
// Remove WAL/SHM files if they exist (stale from old connection)
|
||||
try { fs.unlinkSync(dbPath + '-wal'); } catch (e) {}
|
||||
try { fs.unlinkSync(dbPath + '-shm'); } catch (e) {}
|
||||
} catch (e) {
|
||||
console.error('[Demo Reset] Failed to restore baseline:', e.message);
|
||||
reinitialize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reinitialize DB connection with restored baseline
|
||||
reinitialize();
|
||||
|
||||
// Restore admin's latest credentials (in case admin changed password/API keys after baseline was saved)
|
||||
if (adminData) {
|
||||
try {
|
||||
const { db: freshDb } = require('../db/database');
|
||||
freshDb.prepare(
|
||||
'UPDATE users SET password_hash = ?, maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = ?'
|
||||
).run(
|
||||
adminData.password_hash,
|
||||
adminData.maps_api_key,
|
||||
adminData.openweather_api_key,
|
||||
adminData.unsplash_api_key,
|
||||
adminData.avatar,
|
||||
adminEmail
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Demo Reset] Failed to restore admin credentials:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Demo Reset] Database restored from baseline');
|
||||
}
|
||||
|
||||
function saveBaseline() {
|
||||
const { db } = require('../db/database');
|
||||
|
||||
// Flush WAL so baseline file is self-contained
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
|
||||
fs.copyFileSync(dbPath, baselinePath);
|
||||
console.log('[Demo] Baseline saved');
|
||||
}
|
||||
|
||||
function hasBaseline() {
|
||||
return fs.existsSync(baselinePath);
|
||||
}
|
||||
|
||||
module.exports = { resetDemoUser, saveBaseline, hasBaseline };
|
||||
@@ -0,0 +1,248 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
function seedDemoData(db) {
|
||||
const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin';
|
||||
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
|
||||
const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345';
|
||||
const DEMO_EMAIL = 'demo@nomad.app';
|
||||
const DEMO_PASS = 'demo12345';
|
||||
|
||||
// Create admin user if not exists
|
||||
let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
|
||||
if (!admin) {
|
||||
const hash = bcrypt.hashSync(ADMIN_PASS, 10);
|
||||
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin');
|
||||
admin = { id: Number(r.lastInsertRowid) };
|
||||
console.log('[Demo] Admin user created');
|
||||
} else {
|
||||
admin.id = Number(admin.id);
|
||||
}
|
||||
|
||||
// Create demo user if not exists
|
||||
let demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL);
|
||||
if (!demo) {
|
||||
const hash = bcrypt.hashSync(DEMO_PASS, 10);
|
||||
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('demo', DEMO_EMAIL, hash, 'user');
|
||||
demo = { id: Number(r.lastInsertRowid) };
|
||||
console.log('[Demo] Demo user created');
|
||||
} else {
|
||||
demo.id = Number(demo.id);
|
||||
}
|
||||
|
||||
// Disable registration in demo mode
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
|
||||
// Check if admin already has example trips
|
||||
const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id);
|
||||
if (adminTrips.count > 0) {
|
||||
console.log('[Demo] Example trips already exist, ensuring demo membership');
|
||||
ensureDemoMembership(db, admin.id, demo.id);
|
||||
return { adminId: admin.id, demoId: demo.id };
|
||||
}
|
||||
|
||||
console.log('[Demo] Seeding example trips...');
|
||||
seedExampleTrips(db, admin.id, demo.id);
|
||||
|
||||
// Auto-save baseline after first seed
|
||||
const { saveBaseline, hasBaseline } = require('./demo-reset');
|
||||
if (!hasBaseline()) {
|
||||
saveBaseline();
|
||||
}
|
||||
|
||||
return { adminId: admin.id, demoId: demo.id };
|
||||
}
|
||||
|
||||
function ensureDemoMembership(db, adminId, demoId) {
|
||||
const trips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(adminId);
|
||||
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
|
||||
for (const trip of trips) {
|
||||
insertMember.run(trip.id, demoId, adminId);
|
||||
}
|
||||
}
|
||||
|
||||
function seedExampleTrips(db, adminId, demoId) {
|
||||
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
||||
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)');
|
||||
const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)');
|
||||
const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
|
||||
|
||||
// ─── Trip 1: Tokyo & Kyoto ───
|
||||
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Zwei Wochen Japan — von den neonbeleuchteten Strassen Tokyos bis zu den stillen Tempeln Kyotos.', '2026-04-15', '2026-04-21', 'EUR');
|
||||
const t1 = Number(trip1.lastInsertRowid);
|
||||
|
||||
const t1days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = insertDay.run(t1, i + 1, `2026-04-${15 + i}`);
|
||||
t1days.push(Number(d.lastInsertRowid));
|
||||
}
|
||||
|
||||
// Places — cat IDs: 1=Hotel, 2=Restaurant, 3=Sehenswuerdigkeit, 5=Transport, 7=Bar/Cafe, 9=Natur
|
||||
const t1places = [
|
||||
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, 'Shinjuku, Tokyo, Japan', 1, '15:00', 60, 'Check-in ab 15 Uhr. Nahe Shinjuku Station.', null],
|
||||
[t1, 'Senso-ji Tempel', 35.7148, 139.7967, 'Asakusa, Tokyo, Japan', 3, '09:00', 90, 'Aeltester Tempel Tokyos. Morgens weniger Touristen.', null],
|
||||
[t1, 'Shibuya Crossing', 35.6595, 139.7004, 'Shibuya, Tokyo, Japan', 3, '18:00', 45, 'Die beruehmteste Kreuzung der Welt. Abends am beeindruckendsten.', null],
|
||||
[t1, 'Tsukiji Outer Market', 35.6654, 139.7707, 'Tsukiji, Tokyo, Japan', 2, '08:00', 120, 'Frisches Sushi zum Fruehstueck! Strassenstaende erkunden.', null],
|
||||
[t1, 'Meiji-Schrein', 35.6764, 139.6993, 'Shibuya, Tokyo, Japan', 3, '10:00', 75, 'Ruhige Oase mitten in der Stadt. Durch den Wald zum Schrein.', null],
|
||||
[t1, 'Akihabara', 35.7023, 139.7745, 'Akihabara, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — Anime, Manga, Elektronik. Retro-Gaming Shops!', null],
|
||||
[t1, 'Shinkansen nach Kyoto', 35.6812, 139.7671, 'Tokyo Station, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, ca. 2h15. Fensterplatz fuer Fuji-Blick!', null],
|
||||
[t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Kyoto Station, Kyoto, Japan', 1, '14:00', 60, 'Direkt am Bahnhof. Perfekte Lage fuer Tagesausfluege.', null],
|
||||
[t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, 'Fushimi, Kyoto, Japan', 3, '07:00', 150, '10.000 rote Torii-Tore. Frueh morgens starten fuer leere Wege!', null],
|
||||
[t1, 'Kinkaku-ji (Goldener Pavillon)', 35.0394, 135.7292, 'Kita, Kyoto, Japan', 3, '10:00', 60, 'Der goldene Tempel am See. Ikonisches Fotomotiv.', null],
|
||||
[t1, 'Arashiyama Bambushain', 35.0095, 135.6673, 'Arashiyama, Kyoto, Japan', 9, '09:00', 90, 'Magischer Bambuswald. Am besten morgens vor den Massen.', null],
|
||||
[t1, 'Nishiki Market', 35.0050, 135.7647, 'Nakagyo, Kyoto, Japan', 2, '12:00', 90, 'Kyotos Kuechengasse. Matcha-Eis und frische Mochi probieren!', null],
|
||||
[t1, 'Gion Viertel', 35.0037, 135.7755, 'Gion, Kyoto, Japan', 3, '17:00', 120, 'Historisches Geisha-Viertel. Abends beste Chance auf Maiko-Sichtung.', null],
|
||||
];
|
||||
|
||||
const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||
|
||||
// Assign places to days
|
||||
// Day 1: Hotel Check-in, Shibuya
|
||||
insertAssignment.run(t1days[0], t1pIds[0], 0);
|
||||
insertAssignment.run(t1days[0], t1pIds[2], 1);
|
||||
// Day 2: Tsukiji, Senso-ji, Akihabara
|
||||
insertAssignment.run(t1days[1], t1pIds[3], 0);
|
||||
insertAssignment.run(t1days[1], t1pIds[1], 1);
|
||||
insertAssignment.run(t1days[1], t1pIds[5], 2);
|
||||
// Day 3: Meiji-Schrein, free afternoon
|
||||
insertAssignment.run(t1days[2], t1pIds[4], 0);
|
||||
// Day 4: Shinkansen to Kyoto, Hotel
|
||||
insertAssignment.run(t1days[3], t1pIds[6], 0);
|
||||
insertAssignment.run(t1days[3], t1pIds[7], 1);
|
||||
// Day 5: Fushimi Inari, Nishiki Market
|
||||
insertAssignment.run(t1days[4], t1pIds[8], 0);
|
||||
insertAssignment.run(t1days[4], t1pIds[11], 1);
|
||||
// Day 6: Kinkaku-ji, Arashiyama
|
||||
insertAssignment.run(t1days[5], t1pIds[9], 0);
|
||||
insertAssignment.run(t1days[5], t1pIds[10], 1);
|
||||
// Day 7: Gion
|
||||
insertAssignment.run(t1days[6], t1pIds[12], 0);
|
||||
|
||||
// Packing
|
||||
const t1packing = [
|
||||
['Reisepass', 1, 'Dokumente', 0], ['Japan Rail Pass', 1, 'Dokumente', 1],
|
||||
['Adapter Typ A/B', 0, 'Elektronik', 2], ['Kamera + Ladegeraet', 0, 'Elektronik', 3],
|
||||
['Bequeme Laufschuhe', 0, 'Kleidung', 4], ['Regenjacke', 0, 'Kleidung', 5],
|
||||
['Sonnencreme', 0, 'Hygiene', 6], ['Reiseapotheke', 0, 'Hygiene', 7],
|
||||
['Pocket WiFi Bestaetigung', 1, 'Elektronik', 8], ['Yen Bargeld', 0, 'Dokumente', 9],
|
||||
];
|
||||
t1packing.forEach(p => insertPacking.run(t1, ...p));
|
||||
|
||||
// Budget
|
||||
insertBudget.run(t1, 'Unterkunft', 'Hotel Shinjuku (3 Naechte)', 450, 2, 'Doppelzimmer');
|
||||
insertBudget.run(t1, 'Unterkunft', 'Hotel Granvia Kyoto (4 Naechte)', 680, 2, 'Superior Room');
|
||||
insertBudget.run(t1, 'Transport', 'Fluege FRA-NRT', 1200, 2, 'Lufthansa Direktflug');
|
||||
insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 Tage)', 380, 2, 'Ordinaer');
|
||||
insertBudget.run(t1, 'Essen', 'Tagesbudget Essen', 350, 2, 'Ca. 50 EUR/Tag');
|
||||
insertBudget.run(t1, 'Aktivitaeten', 'Tempel-Eintritte & Erlebnisse', 120, 2, null);
|
||||
|
||||
// Reservations
|
||||
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
|
||||
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
|
||||
|
||||
// Share with demo user
|
||||
insertMember.run(t1, demoId, adminId);
|
||||
|
||||
// ─── Trip 2: Barcelona Citytrip ───
|
||||
const trip2 = insertTrip.run(adminId, 'Barcelona Citytrip', 'Gaudi, Tapas und Meerblick — ein langes Wochenende in Kataloniens Hauptstadt.', '2026-05-21', '2026-05-24', 'EUR');
|
||||
const t2 = Number(trip2.lastInsertRowid);
|
||||
|
||||
const t2days = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const d = insertDay.run(t2, i + 1, `2026-05-${21 + i}`);
|
||||
t2days.push(Number(d.lastInsertRowid));
|
||||
}
|
||||
|
||||
const t2places = [
|
||||
[t2, 'Hotel W Barcelona', 41.3686, 2.1920, 'Barceloneta, Barcelona, Spain', 1, '14:00', 60, 'Direkt am Strand. Rooftop-Bar mit Panorama!', null],
|
||||
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'Eixample, Barcelona, Spain', 3, '10:00', 120, 'Gaudis Meisterwerk. Tickets unbedingt vorher online buchen!', null],
|
||||
[t2, 'Park Gueell', 41.4145, 2.1527, 'Gracia, Barcelona, Spain', 3, '09:00', 90, 'Mosaik-Terrasse mit Stadtblick. Frueh buchen fuer Monumental Zone.', null],
|
||||
[t2, 'La Boqueria', 41.3816, 2.1717, 'La Rambla, Barcelona, Spain', 2, '12:00', 75, 'Beruehmter Markt an der Rambla. Frischer Saft und Jamon Iberico!', null],
|
||||
[t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Barceloneta, Barcelona, Spain', 8, '16:00', 120, 'Stadtstrand zum Entspannen nach dem Sightseeing.', null],
|
||||
[t2, 'Barri Gotic', 41.3834, 2.1762, 'Ciutat Vella, Barcelona, Spain', 3, '15:00', 90, 'Mittelalterliche Gassen. Kathedrale und Placa Reial entdecken.', null],
|
||||
[t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, Barcelona, Spain', 3, '11:00', 75, 'Gaudis Drachen-Haus. Die Fassade allein ist schon ein Erlebnis.', null],
|
||||
[t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, Barcelona, Spain', 7, '20:00', 120, 'Trendviertel mit den besten Tapas-Bars. Cal Pep oder El Xampanyet!', null],
|
||||
];
|
||||
|
||||
const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||
|
||||
// Day 1: Arrival, Beach, El Born
|
||||
insertAssignment.run(t2days[0], t2pIds[0], 0);
|
||||
insertAssignment.run(t2days[0], t2pIds[4], 1);
|
||||
insertAssignment.run(t2days[0], t2pIds[7], 2);
|
||||
// Day 2: Sagrada Familia, Casa Batllo, La Boqueria
|
||||
insertAssignment.run(t2days[1], t2pIds[1], 0);
|
||||
insertAssignment.run(t2days[1], t2pIds[6], 1);
|
||||
insertAssignment.run(t2days[1], t2pIds[3], 2);
|
||||
// Day 3: Park Gueell, Barri Gotic
|
||||
insertAssignment.run(t2days[2], t2pIds[2], 0);
|
||||
insertAssignment.run(t2days[2], t2pIds[5], 1);
|
||||
// Day 4: Free morning, departure
|
||||
insertAssignment.run(t2days[3], t2pIds[4], 0);
|
||||
|
||||
// Packing
|
||||
['Reisepass', 'Sonnencreme SPF50', 'Badehose/Bikini', 'Sonnenbrille', 'Bequeme Sandalen', 'Strandtuch'].forEach((name, i) => {
|
||||
insertPacking.run(t2, name, 0, i < 1 ? 'Dokumente' : 'Sommer', i);
|
||||
});
|
||||
|
||||
// Budget
|
||||
insertBudget.run(t2, 'Unterkunft', 'Hotel W Barcelona (3 Naechte)', 780, 2, 'Sea View Room');
|
||||
insertBudget.run(t2, 'Transport', 'Fluege BER-BCN', 180, 2, 'Eurowings');
|
||||
insertBudget.run(t2, 'Essen', 'Restaurants & Tapas', 300, 2, 'Ca. 75 EUR/Tag');
|
||||
insertBudget.run(t2, 'Aktivitaeten', 'Sagrada Familia + Park Gueell + Casa Batllo', 95, 2, 'Online-Tickets');
|
||||
|
||||
insertReservation.run(t2, t2days[1], 'Sagrada Familia Eintritt', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
|
||||
|
||||
insertMember.run(t2, demoId, adminId);
|
||||
|
||||
// ─── Trip 3: Wochenende in Wien ───
|
||||
const trip3 = insertTrip.run(adminId, 'Wochenende in Wien', 'Kaffeehaus-Kultur, imperiale Pracht und Sachertorte.', '2026-06-12', '2026-06-14', 'EUR');
|
||||
const t3 = Number(trip3.lastInsertRowid);
|
||||
|
||||
const t3days = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = insertDay.run(t3, i + 1, `2026-06-${12 + i}`);
|
||||
t3days.push(Number(d.lastInsertRowid));
|
||||
}
|
||||
|
||||
const t3places = [
|
||||
[t3, 'Hotel Sacher Wien', 48.2038, 16.3699, 'Philharmonikerstrasse 4, Wien, Austria', 1, '15:00', 45, 'Das legendaere Hotel. Sachertorte im Cafe muss sein!', null],
|
||||
[t3, 'Stephansdom', 48.2082, 16.3738, 'Stephansplatz, Wien, Austria', 3, '10:00', 60, 'Wahrzeichen Wiens. Turmbesteigung fuer 360-Grad-Blick.', null],
|
||||
[t3, 'Schloss Schoenbrunn', 48.1845, 16.3122, 'Schoenbrunn, Wien, Austria', 3, '09:30', 150, 'Imperiale Pracht. Grand Tour Ticket fuer alle 40 Raeume.', null],
|
||||
[t3, 'Naschmarkt', 48.1986, 16.3633, 'Wienzeile, Wien, Austria', 2, '12:00', 75, 'Wiens groesster Markt. Orientalische Gewuerze bis Wiener Schnitzel.', null],
|
||||
[t3, 'Cafe Central', 48.2107, 16.3654, 'Herrengasse 14, Wien, Austria', 7, '15:00', 60, 'Wo einst Trotzki Schach spielte. Melange und Apfelstrudel!', null],
|
||||
[t3, 'Prater & Riesenrad', 48.2166, 16.3964, 'Prater, Wien, Austria', 6, '17:00', 90, 'Riesenrad bei Sonnenuntergang. Blick ueber die ganze Stadt.', null],
|
||||
];
|
||||
|
||||
const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||
|
||||
// Day 1: Arrival, Stephansdom, Cafe Central
|
||||
insertAssignment.run(t3days[0], t3pIds[0], 0);
|
||||
insertAssignment.run(t3days[0], t3pIds[1], 1);
|
||||
insertAssignment.run(t3days[0], t3pIds[4], 2);
|
||||
// Day 2: Schoenbrunn, Naschmarkt, Prater
|
||||
insertAssignment.run(t3days[1], t3pIds[2], 0);
|
||||
insertAssignment.run(t3days[1], t3pIds[3], 1);
|
||||
insertAssignment.run(t3days[1], t3pIds[5], 2);
|
||||
// Day 3: Free morning
|
||||
insertAssignment.run(t3days[2], t3pIds[4], 0);
|
||||
|
||||
// Packing
|
||||
['Personalausweis', 'Regenschirm', 'Bequeme Schuhe', 'Kamera'].forEach((name, i) => {
|
||||
insertPacking.run(t3, name, 0, i < 1 ? 'Dokumente' : 'Sonstiges', i);
|
||||
});
|
||||
|
||||
// Budget
|
||||
insertBudget.run(t3, 'Unterkunft', 'Hotel Sacher (2 Naechte)', 520, 2, 'Classic Doppelzimmer');
|
||||
insertBudget.run(t3, 'Transport', 'Zug MUC-VIE', 60, 2, 'OeBB Sparschiene');
|
||||
insertBudget.run(t3, 'Essen', 'Restaurants & Cafes', 200, 2, null);
|
||||
|
||||
insertMember.run(t3, demoId, adminId);
|
||||
|
||||
console.log('[Demo] 3 example trips seeded and shared with demo user');
|
||||
}
|
||||
|
||||
module.exports = { seedDemoData };
|
||||
@@ -0,0 +1,125 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Create upload directories on startup
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
const photosDir = path.join(uploadsDir, 'photos');
|
||||
const filesDir = path.join(uploadsDir, 'files');
|
||||
const coversDir = path.join(uploadsDir, 'covers');
|
||||
const backupsDir = path.join(__dirname, '../data/backups');
|
||||
const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
|
||||
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// Middleware
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
: null;
|
||||
|
||||
let corsOrigin;
|
||||
if (allowedOrigins) {
|
||||
// Explicit whitelist from env var
|
||||
corsOrigin = (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
// Production: same-origin only (Express serves the static client)
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
// Development: allow all origins (needed for Vite dev server)
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Serve uploaded files
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth');
|
||||
const tripsRoutes = require('./routes/trips');
|
||||
const daysRoutes = require('./routes/days');
|
||||
const placesRoutes = require('./routes/places');
|
||||
const assignmentsRoutes = require('./routes/assignments');
|
||||
const packingRoutes = require('./routes/packing');
|
||||
const tagsRoutes = require('./routes/tags');
|
||||
const categoriesRoutes = require('./routes/categories');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const mapsRoutes = require('./routes/maps');
|
||||
const filesRoutes = require('./routes/files');
|
||||
const reservationsRoutes = require('./routes/reservations');
|
||||
const dayNotesRoutes = require('./routes/dayNotes');
|
||||
const weatherRoutes = require('./routes/weather');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const budgetRoutes = require('./routes/budget');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/trips', tripsRoutes);
|
||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
app.use(express.static(publicPath));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
const scheduler = require('./scheduler');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`NOMAD API running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
||||
scheduler.start();
|
||||
scheduler.startDemoReset();
|
||||
const { setupWebSocket } = require('./websocket');
|
||||
setupWebSocket(server);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,63 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../db/database');
|
||||
const { JWT_SECRET } = require('../config');
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
).get(decoded.id);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
};
|
||||
|
||||
const optionalAuth = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
).get(decoded.id);
|
||||
req.user = user || null;
|
||||
} catch (err) {
|
||||
req.user = null;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const adminOnly = (req, res, next) => {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const demoUploadBlock = (req, res, next) => {
|
||||
if (process.env.DEMO_MODE === 'true' && req.user?.email === 'demo@nomad.app') {
|
||||
return res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { authenticate, optionalAuth, adminOnly, demoUploadBlock };
|
||||
@@ -0,0 +1,127 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db } = require('../db/database');
|
||||
const { authenticate, adminOnly } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All admin routes require authentication and admin role
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
// GET /api/admin/users
|
||||
router.get('/users', (req, res) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||
).all();
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
// POST /api/admin/users
|
||||
router.post('/users', (req, res) => {
|
||||
const { username, email, password, role } = req.body;
|
||||
|
||||
if (!username?.trim() || !email?.trim() || !password?.trim()) {
|
||||
return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' });
|
||||
}
|
||||
|
||||
if (role && !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
|
||||
if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
||||
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
|
||||
if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password.trim(), 10);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username.trim(), email.trim(), passwordHash, role || 'user');
|
||||
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ user });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id
|
||||
router.put('/users/:id', (req, res) => {
|
||||
const { username, email, role, password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
if (role && !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
if (username && username !== user.username) {
|
||||
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
|
||||
if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
||||
}
|
||||
if (email && email !== user.email) {
|
||||
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
|
||||
if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
|
||||
}
|
||||
|
||||
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET
|
||||
username = COALESCE(?, username),
|
||||
email = COALESCE(?, email),
|
||||
role = COALESCE(?, role),
|
||||
password_hash = COALESCE(?, password_hash),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(username || null, email || null, role || null, passwordHash, req.params.id);
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(req.params.id);
|
||||
|
||||
res.json({ user: updated });
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id
|
||||
router.delete('/users/:id', (req, res) => {
|
||||
if (parseInt(req.params.id) === req.user.id) {
|
||||
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/admin/stats
|
||||
router.get('/stats', (req, res) => {
|
||||
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
|
||||
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
|
||||
const totalPhotos = db.prepare('SELECT COUNT(*) as count FROM photos').get().count;
|
||||
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
|
||||
|
||||
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles });
|
||||
});
|
||||
|
||||
// POST /api/admin/save-demo-baseline (demo mode only)
|
||||
router.post('/save-demo-baseline', (req, res) => {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
try {
|
||||
const { saveBaseline } = require('../demo/demo-reset');
|
||||
saveBaseline();
|
||||
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to save baseline: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,252 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
function verifyTripOwnership(tripId, userId) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
function getAssignmentWithPlace(assignmentId) {
|
||||
const a = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`).get(assignmentId);
|
||||
|
||||
if (!a) return null;
|
||||
|
||||
const tags = db.prepare(`
|
||||
SELECT t.* FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id = ?
|
||||
`).all(a.place_id);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
description: a.place_description,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
address: a.address,
|
||||
category_id: a.category_id,
|
||||
price: a.price,
|
||||
currency: a.place_currency,
|
||||
reservation_status: a.reservation_status,
|
||||
reservation_notes: a.reservation_notes,
|
||||
reservation_datetime: a.reservation_datetime,
|
||||
place_time: a.place_time,
|
||||
duration_minutes: a.duration_minutes,
|
||||
notes: a.place_notes,
|
||||
image_url: a.image_url,
|
||||
transport_mode: a.transport_mode,
|
||||
google_place_id: a.google_place_id,
|
||||
website: a.website,
|
||||
phone: a.phone,
|
||||
category: a.category_id ? {
|
||||
id: a.category_id,
|
||||
name: a.category_name,
|
||||
color: a.category_color,
|
||||
icon: a.category_icon,
|
||||
} : null,
|
||||
tags,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/trips/:tripId/days/:dayId/assignments
|
||||
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
|
||||
const { tripId, dayId } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
||||
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(dayId);
|
||||
|
||||
// Batch-load all tags for all places in one query to avoid N+1
|
||||
const placeIds = [...new Set(assignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = {};
|
||||
if (placeIds.length > 0) {
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const allTags = db.prepare(`
|
||||
SELECT t.*, pt.place_id FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id IN (${placeholders})
|
||||
`).all(...placeIds);
|
||||
for (const tag of allTags) {
|
||||
if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = [];
|
||||
tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
const result = assignments.map(a => {
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
name: a.place_name,
|
||||
description: a.place_description,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
address: a.address,
|
||||
category_id: a.category_id,
|
||||
price: a.price,
|
||||
currency: a.place_currency,
|
||||
reservation_status: a.reservation_status,
|
||||
reservation_notes: a.reservation_notes,
|
||||
reservation_datetime: a.reservation_datetime,
|
||||
place_time: a.place_time,
|
||||
duration_minutes: a.duration_minutes,
|
||||
notes: a.place_notes,
|
||||
image_url: a.image_url,
|
||||
transport_mode: a.transport_mode,
|
||||
google_place_id: a.google_place_id,
|
||||
website: a.website,
|
||||
phone: a.phone,
|
||||
category: a.category_id ? {
|
||||
id: a.category_id,
|
||||
name: a.category_name,
|
||||
color: a.category_color,
|
||||
icon: a.category_icon,
|
||||
} : null,
|
||||
tags: tagsByPlaceId[a.place_id] || [],
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ assignments: result });
|
||||
});
|
||||
|
||||
// POST /api/trips/:tripId/days/:dayId/assignments
|
||||
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
|
||||
const { tripId, dayId } = req.params;
|
||||
const { place_id, notes } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
||||
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
|
||||
if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' });
|
||||
|
||||
const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id);
|
||||
if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' });
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
|
||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, place_id, orderIndex, notes || null);
|
||||
|
||||
const assignment = getAssignmentWithPlace(result.lastInsertRowid);
|
||||
res.status(201).json({ assignment });
|
||||
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/days/:dayId/assignments/:id
|
||||
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req, res) => {
|
||||
const { tripId, dayId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const assignment = db.prepare(
|
||||
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
|
||||
).get(id, dayId, tripId);
|
||||
|
||||
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
|
||||
|
||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/days/:dayId/assignments/reorder
|
||||
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req, res) => {
|
||||
const { tripId, dayId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
||||
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
orderedIds.forEach((id, index) => {
|
||||
update.run(index, id, dayId);
|
||||
});
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/assignments/:id/move
|
||||
router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { new_day_id, order_index } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const assignment = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da
|
||||
JOIN days d ON da.day_id = d.id
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`).get(id, tripId);
|
||||
|
||||
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
|
||||
|
||||
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
|
||||
if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' });
|
||||
|
||||
const oldDayId = assignment.day_id;
|
||||
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
|
||||
|
||||
const updated = getAssignmentWithPlace(id);
|
||||
res.json({ assignment: updated });
|
||||
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,416 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const fetch = require('node-fetch');
|
||||
const { db } = require('../db/database');
|
||||
const { authenticate, demoUploadBlock } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const { JWT_SECRET } = require('../config');
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
|
||||
|
||||
const avatarStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, avatarDir),
|
||||
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
|
||||
});
|
||||
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
||||
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
}});
|
||||
|
||||
// Simple rate limiter
|
||||
const loginAttempts = new Map();
|
||||
function rateLimiter(maxAttempts, windowMs) {
|
||||
return (req, res, next) => {
|
||||
const key = req.ip;
|
||||
const now = Date.now();
|
||||
const record = loginAttempts.get(key);
|
||||
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
||||
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
|
||||
}
|
||||
if (!record || now - record.first >= windowMs) {
|
||||
loginAttempts.set(key, { count: 1, first: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
const authLimiter = rateLimiter(10, 15 * 60 * 1000); // 10 attempts per 15 minutes
|
||||
|
||||
function avatarUrl(user) {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, username: user.username, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/auth/app-config (public — no auth needed)
|
||||
router.get('/app-config', (req, res) => {
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
||||
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const { version } = require('../../package.json');
|
||||
res.json({
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
has_users: userCount > 0,
|
||||
version,
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/auth/demo-login (demo mode only)
|
||||
router.post('/demo-login', (req, res) => {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app');
|
||||
if (!user) return res.status(500).json({ error: 'Demo user not found' });
|
||||
const token = generateToken(user);
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
|
||||
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', authLimiter, (req, res) => {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// Check if registration is allowed
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
if (userCount > 0) {
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
||||
if (setting?.value === 'false') {
|
||||
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'Username, email and password are required' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'A user with this email or username already exists' });
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
|
||||
// First user becomes admin
|
||||
const isFirstUser = userCount === 0;
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, password_hash, role);
|
||||
|
||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
|
||||
const token = generateToken(user);
|
||||
|
||||
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', authLimiter, (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
||||
}
|
||||
|
||||
const validPassword = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
||||
|
||||
res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', authenticate, (req, res) => {
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/maps-key
|
||||
router.put('/me/maps-key', authenticate, (req, res) => {
|
||||
const { maps_api_key } = req.body;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(maps_api_key || null, req.user.id);
|
||||
|
||||
res.json({ success: true, maps_api_key: maps_api_key || null });
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/api-keys
|
||||
router.put('/me/api-keys', authenticate, (req, res) => {
|
||||
const { maps_api_key, openweather_api_key } = req.body;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(
|
||||
maps_api_key !== undefined ? (maps_api_key || null) : req.user.maps_api_key,
|
||||
openweather_api_key !== undefined ? (openweather_api_key || null) : req.user.openweather_api_key,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
|
||||
});
|
||||
|
||||
// PUT /api/auth/me/settings
|
||||
router.put('/me/settings', authenticate, (req, res) => {
|
||||
const { maps_api_key, openweather_api_key, username, email } = req.body;
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
|
||||
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email); }
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(req.user.id);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
}
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
|
||||
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
|
||||
});
|
||||
|
||||
// GET /api/auth/me/settings (admin only — returns API keys)
|
||||
router.get('/me/settings', authenticate, (req, res) => {
|
||||
const user = db.prepare(
|
||||
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
|
||||
});
|
||||
|
||||
// POST /api/auth/avatar — upload avatar
|
||||
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
|
||||
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
|
||||
if (current && current.avatar) {
|
||||
const oldPath = path.join(avatarDir, current.avatar);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
|
||||
const filename = req.file.filename;
|
||||
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, req.user.id);
|
||||
|
||||
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json({ success: true, avatar_url: avatarUrl(updated) });
|
||||
});
|
||||
|
||||
// DELETE /api/auth/avatar — remove avatar
|
||||
router.delete('/avatar', authenticate, (req, res) => {
|
||||
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
|
||||
if (current && current.avatar) {
|
||||
const filePath = path.join(avatarDir, current.avatar);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/users — list all users (for sharing/inviting)
|
||||
router.get('/users', authenticate, (req, res) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
|
||||
).all(req.user.id);
|
||||
res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) });
|
||||
});
|
||||
|
||||
// GET /api/auth/validate-keys (admin only)
|
||||
router.get('/validate-keys', authenticate, async (req, res) => {
|
||||
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(req.user.id);
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const result = { maps: false, weather: false };
|
||||
|
||||
// Test Google Maps Places API
|
||||
if (user.maps_api_key) {
|
||||
try {
|
||||
const mapsRes = await fetch(
|
||||
`https://places.googleapis.com/v1/places:searchText`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': user.maps_api_key,
|
||||
'X-Goog-FieldMask': 'places.displayName',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: 'test' }),
|
||||
}
|
||||
);
|
||||
result.maps = mapsRes.status === 200;
|
||||
} catch (err) {
|
||||
result.maps = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test OpenWeatherMap API
|
||||
if (user.openweather_api_key) {
|
||||
try {
|
||||
const weatherRes = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
|
||||
);
|
||||
result.weather = weatherRes.status === 200;
|
||||
} catch (err) {
|
||||
result.weather = false;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// PUT /api/auth/app-settings (admin only)
|
||||
router.put('/app-settings', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration } = req.body;
|
||||
if (allow_registration !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/travel-stats — aggregated travel statistics for current user
|
||||
router.get('/travel-stats', authenticate, (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get all places from user's trips (owned + shared)
|
||||
const places = db.prepare(`
|
||||
SELECT DISTINCT p.address, p.lat, p.lng
|
||||
FROM places p
|
||||
JOIN trips t ON p.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE t.user_id = ? OR tm.user_id = ?
|
||||
`).all(userId, userId);
|
||||
|
||||
// Get trip count + total days
|
||||
const tripStats = db.prepare(`
|
||||
SELECT COUNT(DISTINCT t.id) as trips,
|
||||
COUNT(DISTINCT d.id) as days
|
||||
FROM trips t
|
||||
LEFT JOIN days d ON d.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
|
||||
`).get(userId, userId);
|
||||
|
||||
// Known country names (EN + DE)
|
||||
const KNOWN_COUNTRIES = new Set([
|
||||
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
|
||||
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
|
||||
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
|
||||
'South Korea', 'Südkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Türkei', 'Türkiye',
|
||||
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
|
||||
'Switzerland', 'Schweiz', 'Austria', 'Österreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
|
||||
'Denmark', 'Dänemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
|
||||
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumänien',
|
||||
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
|
||||
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
|
||||
'Egypt', 'Ägypten', 'Morocco', 'Marokko', 'South Africa', 'Südafrika', 'Kenya', 'Kenia',
|
||||
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
|
||||
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
|
||||
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
|
||||
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
|
||||
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
|
||||
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
|
||||
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
|
||||
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
|
||||
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
|
||||
'Tanzania', 'Tansania', 'Ethiopia', 'Äthiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
|
||||
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
|
||||
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
|
||||
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weißrussland',
|
||||
]);
|
||||
|
||||
// Extract countries from addresses — only accept known country names
|
||||
const countries = new Set();
|
||||
const cities = new Set();
|
||||
const coords = [];
|
||||
|
||||
places.forEach(p => {
|
||||
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
|
||||
if (p.address) {
|
||||
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
|
||||
for (const part of parts) {
|
||||
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
|
||||
}
|
||||
// City: first part that's not the country and looks like a name (Latin chars, > 2 chars)
|
||||
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-zÀ-ÿ\s-]{2,}$/.test(s));
|
||||
if (cityPart) cities.add(cityPart);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
countries: [...countries],
|
||||
cities: [...cities],
|
||||
coords,
|
||||
totalTrips: tripStats?.trips || 0,
|
||||
totalDays: tripStats?.days || 0,
|
||||
totalPlaces: places.length,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,233 @@
|
||||
const express = require('express');
|
||||
const archiver = require('archiver');
|
||||
const unzipper = require('unzipper');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authenticate, adminOnly } = require('../middleware/auth');
|
||||
const scheduler = require('../scheduler');
|
||||
const { db, closeDb, reinitialize } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All backup routes require admin
|
||||
router.use(authenticate, adminOnly);
|
||||
|
||||
const dataDir = path.join(__dirname, '../../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
function ensureBackupsDir() {
|
||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// GET /api/backup/list
|
||||
router.get('/list', (req, res) => {
|
||||
ensureBackupsDir();
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(backupsDir)
|
||||
.filter(f => f.endsWith('.zip'))
|
||||
.map(filename => {
|
||||
const filePath = path.join(backupsDir, filename);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
filename,
|
||||
size: stat.size,
|
||||
sizeText: formatSize(stat.size),
|
||||
created_at: stat.birthtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
res.json({ backups: files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/backup/create
|
||||
router.post('/create', async (req, res) => {
|
||||
ensureBackupsDir();
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `backup-${timestamp}.zip`;
|
||||
const outputPath = path.join(backupsDir, filename);
|
||||
|
||||
try {
|
||||
// Flush WAL to main DB file before archiving so all data is captured
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outputPath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
output.on('close', resolve);
|
||||
archive.on('error', reject);
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
// Add database
|
||||
const dbPath = path.join(dataDir, 'travel.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Add uploads directory
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
archive.directory(uploadsDir, 'uploads');
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
const stat = fs.statSync(outputPath);
|
||||
res.json({
|
||||
success: true,
|
||||
backup: {
|
||||
filename,
|
||||
size: stat.size,
|
||||
sizeText: formatSize(stat.size),
|
||||
created_at: stat.birthtime.toISOString(),
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Backup error:', err);
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/backup/download/:filename
|
||||
router.get('/download/:filename', (req, res) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Security: prevent path traversal
|
||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filePath = path.join(backupsDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
||||
}
|
||||
|
||||
res.download(filePath, filename);
|
||||
});
|
||||
|
||||
// Helper: restore from a zip file path
|
||||
async function restoreFromZip(zipPath, res) {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
try {
|
||||
await fs.createReadStream(zipPath)
|
||||
.pipe(unzipper.Extract({ path: extractDir }))
|
||||
.promise();
|
||||
|
||||
const extractedDb = path.join(extractDir, 'travel.db');
|
||||
if (!fs.existsSync(extractedDb)) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
return res.status(400).json({ error: 'Ungültiges Backup: travel.db nicht gefunden' });
|
||||
}
|
||||
|
||||
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
||||
closeDb();
|
||||
|
||||
// Step 2: remove WAL/SHM and overwrite DB file
|
||||
const dbDest = path.join(dataDir, 'travel.db');
|
||||
for (const ext of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Step 3: restore uploads
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
// Step 4: reopen DB with restored data
|
||||
reinitialize();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Restore error:', err);
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message || 'Fehler beim Wiederherstellen' });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/backup/restore/:filename - restore from stored backup
|
||||
router.post('/restore/:filename', async (req, res) => {
|
||||
const { filename } = req.params;
|
||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
const zipPath = path.join(backupsDir, filename);
|
||||
if (!fs.existsSync(zipPath)) {
|
||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
||||
}
|
||||
await restoreFromZip(zipPath, res);
|
||||
});
|
||||
|
||||
// POST /api/backup/upload-restore - upload a zip and restore
|
||||
const uploadTmp = multer({
|
||||
dest: path.join(dataDir, 'tmp/'),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.originalname.endsWith('.zip')) cb(null, true);
|
||||
else cb(new Error('Nur ZIP-Dateien erlaubt'));
|
||||
},
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
|
||||
const zipPath = req.file.path;
|
||||
await restoreFromZip(zipPath, res);
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||
});
|
||||
|
||||
// GET /api/backup/auto-settings
|
||||
router.get('/auto-settings', (req, res) => {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
});
|
||||
|
||||
// PUT /api/backup/auto-settings
|
||||
router.put('/auto-settings', (req, res) => {
|
||||
const { enabled, interval, keep_days } = req.body;
|
||||
const settings = {
|
||||
enabled: !!enabled,
|
||||
interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily',
|
||||
keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7,
|
||||
};
|
||||
scheduler.saveSettings(settings);
|
||||
scheduler.start();
|
||||
res.json({ settings });
|
||||
});
|
||||
|
||||
// DELETE /api/backup/:filename
|
||||
router.delete('/:filename', (req, res) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filePath = path.join(backupsDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
function verifyTripOwnership(tripId, userId) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
// GET /api/trips/:tripId/budget
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
||||
).all(tripId);
|
||||
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
// POST /api/trips/:tripId/budget
|
||||
router.post('/', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { category, name, total_price, persons, days, note } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
category || 'Sonstiges',
|
||||
name,
|
||||
total_price || 0,
|
||||
persons != null ? persons : null,
|
||||
days !== undefined && days !== null ? days : null,
|
||||
note || null,
|
||||
sortOrder
|
||||
);
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ item });
|
||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/budget/:id
|
||||
router.put('/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { category, name, total_price, persons, days, note, sort_order } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
|
||||
|
||||
db.prepare(`
|
||||
UPDATE budget_items SET
|
||||
category = COALESCE(?, category),
|
||||
name = COALESCE(?, name),
|
||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
category || null,
|
||||
name || null,
|
||||
total_price !== undefined ? 1 : null, total_price !== undefined ? total_price : 0,
|
||||
persons !== undefined ? 1 : null, persons !== undefined ? persons : null,
|
||||
days !== undefined ? 1 : 0, days !== undefined ? days : null,
|
||||
note !== undefined ? 1 : 0, note !== undefined ? note : null,
|
||||
sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
|
||||
res.json({ item: updated });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:tripId/budget/:id
|
||||
router.delete('/:id', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
||||
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
|
||||
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user