diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 48d1fd7..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "backend"] - path = backend - url = git@codeberg.org:DishPlannerCommunity/dish-planner-backend.git -[submodule "frontend"] - path = frontend - url = git@codeberg.org:DishPlannerCommunity/dish-planner-frontend.git diff --git a/backend b/backend deleted file mode 160000 index a7581a0..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7581a0aee52716e8a8bc7f70663cfd6bc96e4c7 diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..11024ef --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,71 @@ +APP_NAME=DishPlanner +APP_ENV=local +APP_KEY=base64:Z3WnYIG9I6xxft15P1EO31WHinj1R36eM/iN3ouyFBM= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost:8000 + +SANCTUM_STATEFUL_DOMAINS=localhost:3000 +SESSION_DOMAIN=localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +WWWGROUP=1000 + +DB_CONNECTION=mysql +DB_HOST=mysql +DB_PORT=3306 +DB_DATABASE=dishplanner +DB_USERNAME=dpuser +DB_PASSWORD=dppass + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=file +CACHE_PREFIX=dishplanner + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..be5cd4b --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,24 @@ +/composer.lock +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +/auth.json +/.fleet +/.idea +/.nova +/.vscode +/.zed diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1b43073 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# Use official PHP base with required extensions +FROM php:8.2-fpm + +# Install system dependencies & PHP extensions +RUN apt-get update && apt-get install -y \ + git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev zip \ + && docker-php-ext-install pdo pdo_mysql zip mbstring exif pcntl bcmath + +# Install Composer globally +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working dir +WORKDIR /var/www + +# Copy app files +COPY . . + +# Install PHP dependencies +RUN composer install --no-dev --optimize-autoloader + +# Laravel optimizations +RUN php artisan config:cache \ + && php artisan route:cache \ + && php artisan view:cache + +# Set correct permissions +RUN chown -R www-data:www-data /var/www \ + && chmod -R 755 /var/www/storage + +USER www-data + +# Expose port 9000 (default for php-fpm) +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/backend/LICENSE.md b/backend/LICENSE.md new file mode 100644 index 0000000..6b111d1 --- /dev/null +++ b/backend/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + +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. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + +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 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. Use with the GNU Affero General Public License. + +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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + +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 GPL, see +. + +The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f768271 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,14 @@ +# Dish Planner + +Plan your future dishes + +## Development + +### Installation + +This project uses Laravel Sail, so install all composer packages, get your docker up, and run + +```shell +./vendor/bin/sail up -d +``` + diff --git a/backend/app/Console/Commands/GenerateSchedulesCommand.php b/backend/app/Console/Commands/GenerateSchedulesCommand.php new file mode 100644 index 0000000..98271bd --- /dev/null +++ b/backend/app/Console/Commands/GenerateSchedulesCommand.php @@ -0,0 +1,40 @@ +copy()->addYears(2); + + $this->info("Generating schedules from {$startDate->toDateString()} to {$endDate->toDateString()} for all planners."); + + $planners = Planner::all(); + + if ($planners->isEmpty()) { + $this->warn('No planners found. Aborting schedule generation.'); + return self::FAILURE; + } + + foreach ($planners as $planner) { + $this->info("Processing schedules for Planner ID: {$planner->id}"); + + resolve(GenerateSchedulesForUserAction::class)->execute($planner); + } + + $this->info('Schedule generation for all planners has been completed.'); + + return self::SUCCESS; + } +} diff --git a/backend/app/Exceptions/CustomException.php b/backend/app/Exceptions/CustomException.php new file mode 100644 index 0000000..b5f7bb4 --- /dev/null +++ b/backend/app/Exceptions/CustomException.php @@ -0,0 +1,10 @@ +json(resolve(OutputService::class)->response($success, $payload, $errors), $statusCode); + } + + public function success(?array $payload, int $statusCode = 200): JsonResponse + { + return $this->response(true, $payload, null, $statusCode); + } + + public function error(array|string|null $errors, int $statusCode = 400): JsonResponse + { + return $this->response(false, null, $errors, $statusCode); + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +headers->set('Accept', 'application/json'); + + return $next($request); + } +} diff --git a/backend/app/Http/Resources/MinimalScheduleResource.php b/backend/app/Http/Resources/MinimalScheduleResource.php new file mode 100644 index 0000000..d98cade --- /dev/null +++ b/backend/app/Http/Resources/MinimalScheduleResource.php @@ -0,0 +1,17 @@ + $this->id, + 'date' => $this->date->format('Y-m-d'), + ]; + } +} diff --git a/backend/app/Http/Resources/MinimalScheduledUserDishResource.php b/backend/app/Http/Resources/MinimalScheduledUserDishResource.php new file mode 100644 index 0000000..5772a3b --- /dev/null +++ b/backend/app/Http/Resources/MinimalScheduledUserDishResource.php @@ -0,0 +1,33 @@ + $this->id, + 'schedule' => new MinimalScheduleResource($this->schedule), + 'user' => $this->userDish?->user ? [ + 'id' => $this->userDish->user->id, + 'name' => $this->userDish->user->name, + ] : null, + 'dish' => $this->userDish?->dish ? [ + 'id' => $this->userDish->dish->id, + 'name' => $this->userDish->dish->name, + ] : null, + 'is_skipped' => $this->is_skipped, + ]; + } +} diff --git a/backend/app/Http/Resources/ScheduledUserDishResource.php b/backend/app/Http/Resources/ScheduledUserDishResource.php new file mode 100644 index 0000000..313581a --- /dev/null +++ b/backend/app/Http/Resources/ScheduledUserDishResource.php @@ -0,0 +1,26 @@ + $this->id, + 'schedule' => new MinimalScheduleResource($this->schedule), + 'userDish' => new UserDishResource($this->userDish), + 'is_skipped' => $this->is_skipped, + ]; + } +} diff --git a/backend/app/Http/Resources/UserDishResource.php b/backend/app/Http/Resources/UserDishResource.php new file mode 100644 index 0000000..2f7acb6 --- /dev/null +++ b/backend/app/Http/Resources/UserDishResource.php @@ -0,0 +1,24 @@ + $this->id, + 'user' => new UserResource($this->user), + 'dish' => new DishResource($this->dish), + 'recurrences' => $this->recurrences->map(fn ($recurrence) => [ + 'id' => $recurrence->id, + 'type' => $recurrence->recurrence_type, + 'value' => $recurrence->getValue() + ]), + ]; + } +} diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..e95e00d --- /dev/null +++ b/backend/app/Http/Resources/UserResource.php @@ -0,0 +1,17 @@ + $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/backend/app/Http/Resources/UserWithDishesResource.php b/backend/app/Http/Resources/UserWithDishesResource.php new file mode 100644 index 0000000..122a34b --- /dev/null +++ b/backend/app/Http/Resources/UserWithDishesResource.php @@ -0,0 +1,30 @@ + $this->id, + 'name' => $this->name, + 'dishes' => $this->mapDishes(), + ]; + } + + private function mapDishes(): array + { + return $this->dishes + ->map(fn (Dish $dish) => [ + 'id' => $dish->id, + 'name' => $dish->name, + 'recurrences' => [], + ]) + ->toArray(); + } +} diff --git a/backend/app/Http/Resources/UserWithUserDishesResource.php b/backend/app/Http/Resources/UserWithUserDishesResource.php new file mode 100644 index 0000000..443968a --- /dev/null +++ b/backend/app/Http/Resources/UserWithUserDishesResource.php @@ -0,0 +1,33 @@ + $this->id, + 'name' => $this->name, + 'user_dishes' => $this->mapDishes(), + ]; + } + + private function mapDishes(): array + { + return $this->userDishes + ->map(fn (UserDish $userDish) => [ + 'id' => $userDish->id, + 'dish' => [ + 'id' => $userDish->dish->id, + 'name' => $userDish->dish->name, + ], + 'recurrences' => [], + ]) + ->toArray(); + } +} diff --git a/backend/app/Models/Dish.php b/backend/app/Models/Dish.php new file mode 100755 index 0000000..a1eb1a2 --- /dev/null +++ b/backend/app/Models/Dish.php @@ -0,0 +1,64 @@ + $users + * @property Collection $userDishes + * @method static create(array $data) + * @method static findOrFail(int $dish_id) + * @method static DishFactory factory($count = null, $state = []) + */ +class Dish extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = ['planner_id', 'name', 'recurrence']; + + protected $casts = []; + + protected static function booted(): void + { + static::addGlobalScope(new BelongsToPlanner); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_dishes', 'dish_id', 'user_id') + ->orderBy('name'); + } + + public function userDishes(): HasMany + { + return $this->hasMany(UserDish::class, 'dish_id', 'id'); + } + + public function recurrences(): HasManyThrough + { + return $this->hasManyThrough( + UserDishRecurrence::class, + UserDish::class, + 'dish_id', + 'user_dish_id', + 'id', + 'id' + ); + } +} diff --git a/backend/app/Models/MinimumRecurrence.php b/backend/app/Models/MinimumRecurrence.php new file mode 100755 index 0000000..e91acec --- /dev/null +++ b/backend/app/Models/MinimumRecurrence.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $fillable = ['days']; + + protected $casts = []; + + public function recurrence(): MorphOne + { + return $this->morphOne(UserDishRecurrence::class, 'recurrence'); + } +} diff --git a/backend/app/Models/Planner.php b/backend/app/Models/Planner.php new file mode 100644 index 0000000..156f26c --- /dev/null +++ b/backend/app/Models/Planner.php @@ -0,0 +1,31 @@ +hasMany(Schedule::class); + } +} diff --git a/backend/app/Models/Schedule.php b/backend/app/Models/Schedule.php new file mode 100644 index 0000000..a055479 --- /dev/null +++ b/backend/app/Models/Schedule.php @@ -0,0 +1,61 @@ + $scheduledUserDishes + * @method static create(array $array) + * @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') + * @method static ScheduleFactory factory($count = null, $state = []) + */ +class Schedule extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $table = 'schedules'; + + public $timestamps = false; + + protected $fillable = ['planner_id', 'date', 'is_skipped']; + + protected $casts = [ + 'date' => 'date', + 'is_skipped' => 'boolean', + ]; + + protected static function booted(): void + { + static::addGlobalScope(new BelongsToPlanner); + } + + public function scheduledUserDishes(): HasMany + { + return $this->hasMany(ScheduledUserDish::class); + } + + public function hasAllUsersScheduled(): bool + { + return $this->scheduledUserDishes->count() === User::all()->count(); + } +} diff --git a/backend/app/Models/ScheduledUserDish.php b/backend/app/Models/ScheduledUserDish.php new file mode 100644 index 0000000..0bff736 --- /dev/null +++ b/backend/app/Models/ScheduledUserDish.php @@ -0,0 +1,52 @@ + 'boolean', + ]; + + public function schedule(): BelongsTo + { + return $this->belongsTo(Schedule::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function userDish(): BelongsTo + { + return $this->belongsTo(UserDish::class); + } + + public function scopeForUser(Builder $query, int $userId): Builder + { + return $query->whereHas('userDish', function (Builder $query) use ($userId) { + $query->where('user_id', $userId); + }); + } +} diff --git a/backend/app/Models/Scopes/BelongsToPlanner.php b/backend/app/Models/Scopes/BelongsToPlanner.php new file mode 100644 index 0000000..934aeff --- /dev/null +++ b/backend/app/Models/Scopes/BelongsToPlanner.php @@ -0,0 +1,17 @@ +user()) { + $builder->where('planner_id', $planner->id); + } + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php new file mode 100644 index 0000000..9675e87 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,80 @@ + $dishes + * @property Collection $userDishes + * @method static User findOrFail(int $user_id) + * @method static UserFactory factory($count = null, $state = []) + */ +class User extends Authenticatable +{ + /** @use HasFactory */ + use HasFactory, Notifiable; + + protected $fillable = [ + 'planner_id', + 'name', + 'email', + 'password', + ]; + + protected $hidden = [ + 'password', + 'remember_token', + ]; + + protected static function booted(): void + { + static::addGlobalScope(new BelongsToPlanner); + } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function dishes(): BelongsToMany + { + return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id'); + } + + public function userDishes(): HasMany + { + return $this->hasMany(UserDish::class); + } + + public function recurrences(): HasManyThrough + { + return $this->hasManyThrough( + UserDishRecurrence::class, + UserDish::class, + 'user_id', // Foreign key on user_dishes + 'user_dish_id', // Foreign key on user_dish_recurrences + 'id', // Local key on users + 'id' // Local key on user_dishes + ); + } +} diff --git a/backend/app/Models/UserDish.php b/backend/app/Models/UserDish.php new file mode 100644 index 0000000..a30aab0 --- /dev/null +++ b/backend/app/Models/UserDish.php @@ -0,0 +1,56 @@ + $recurrences + * @property Collection $fixedRecurrences + */ +class UserDish extends Model +{ + use HasFactory; + + protected $fillable = ['user_id', 'dish_id']; + + public function recurrences(): HasMany + { + return $this->hasMany(UserDishRecurrence::class, 'user_dish_id'); + } + + public function fixedRecurrences(): HasMany + { + return $this->hasMany(UserDishRecurrence::class, 'user_dish_id') + ->where('recurrence_type', WeeklyRecurrence::class) + ->join('weekly_recurrences', 'weekly_recurrences.id', '=', 'user_dish_recurrences.recurrence_id') + ->select('weekly_recurrences.*', 'user_dish_recurrences.user_dish_id'); // Ensures we work with weekly_recurrences columns + } + + public function user(): BelongsTo + { + return $this->BelongsTo(User::class); + } + + public function dish(): BelongsTo + { + return $this->BelongsTo(Dish::class); + } +} diff --git a/backend/app/Models/UserDishRecurrence.php b/backend/app/Models/UserDishRecurrence.php new file mode 100755 index 0000000..f968d30 --- /dev/null +++ b/backend/app/Models/UserDishRecurrence.php @@ -0,0 +1,42 @@ + */ + use HasFactory; + + protected $fillable = ['user_dish_id', 'recurrence_id', 'recurrence_type']; + + protected $casts = []; + + public function recurrence(): MorphTo + { + return $this->morphTo(); + } + + public function dishUser(): BelongsTo + { + return $this->belongsTo(UserDish::class, 'user_dish_id', 'id'); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + public function getValue(): int + { + return match ($this->recurrence_type) { + WeeklyRecurrence::class => $this->recurrence->weekday->value, + MinimumRecurrence::class => $this->recurrence->days, + default => throw new InvalidRecurrenceTypeException() + }; + } +} diff --git a/backend/app/Models/WeeklyRecurrence.php b/backend/app/Models/WeeklyRecurrence.php new file mode 100755 index 0000000..fa3b418 --- /dev/null +++ b/backend/app/Models/WeeklyRecurrence.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + protected $fillable = ['weekday']; + + protected $casts = [ + 'weekday' => WeekdaysEnum::class, + ]; + + public function recurrence(): MorphOne + { + return $this->morphOne(UserDishRecurrence::class, 'recurrence'); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100755 index 0000000..0bbbb1e --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,54 @@ +app->bind(ExceptionHandler::class, function ($app) { + return new class($app) extends BaseHandler { + public function render($request, Throwable $e) + { + // Handle specific custom exception + if ($e instanceof CustomException) { + return response()->json([ + 'success' => false, + 'payload' => null, + 'errors' => [$e->getMessage()], + ], $e->getCode() ?? 500); + } + + return parent::render($request, $e); + } + }; + }); + } + + public function boot(): void + { + Gate::policy(Dish::class, DishPolicy::class); + Gate::policy(Schedule::class, SchedulePolicy::class); + Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class); + Gate::policy(User::class, UserPolicy::class); + Gate::policy(UserDish::class, UserDishPolicy::class); + } +} diff --git a/backend/app/Services/OutputService.php b/backend/app/Services/OutputService.php new file mode 100644 index 0000000..2e7882e --- /dev/null +++ b/backend/app/Services/OutputService.php @@ -0,0 +1,26 @@ + $success, + 'payload' => $payload, + 'errors' => $errors, + ]; + } + + public function success(?array $payload): array + { + return $this->response(true, $payload, null); + } + + public function error(array|string|null $errors): array + { + return $this->response(false, null, $errors); + } +} diff --git a/backend/app/WeekdaysEnum.php b/backend/app/WeekdaysEnum.php new file mode 100644 index 0000000..1f50b3b --- /dev/null +++ b/backend/app/WeekdaysEnum.php @@ -0,0 +1,14 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/backend/bin/build_and_push.sh b/backend/bin/build_and_push.sh new file mode 100755 index 0000000..b3ce7d4 --- /dev/null +++ b/backend/bin/build_and_push.sh @@ -0,0 +1,3 @@ +#!/bin/bash +docker build -t 192.168.178.152:50114/dishplanner-backend . +docker push 192.168.178.152:50114/dishplanner-backend diff --git a/backend/bin/update.sh b/backend/bin/update.sh new file mode 100755 index 0000000..7320639 --- /dev/null +++ b/backend/bin/update.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +echo "🔄 Pulling latest backend changes..." +git pull origin main + +echo "📦 Installing PHP dependencies..." +composer install --no-interaction --prefer-dist --optimize-autoloader + +echo "🗄️ Running migrations..." +php artisan migrate --force + +echo "🧹 Clearing and caching config..." +php artisan config:cache +php artisan route:cache +php artisan view:cache + +echo "✅ Backend update complete!" diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..265ca64 --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,61 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->append(ForceJsonResponse::class); + $middleware->append(StartSession::class); + $middleware->append(HandleCors::class); + }) + ->withExceptions(function (Exceptions $exceptions) { + $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + if ($request->is('api/*')) { + return true; + } + + return $request->expectsJson(); + }); + + /** @var OutputService $outputService */ + $outputService = resolve(OutputService::class); + + $exceptions->render(fn (ValidationException $e, Request $request) => $outputService + ->response(false, null, [$e->getMessage()], 404) + ); + + $exceptions->render(fn (NotFoundHttpException $e, Request $request) => response()->json( + $outputService->response(false, null, ['MODEL_NOT_FOUND']), + 404 + )); + + $exceptions->render(fn (AccessDeniedHttpException $e, Request $request) => response()->json( + $outputService->response(false, null, [$e->getMessage()]), + 403 + )); + }) + ->withCommands([ + GenerateScheduleCommand::class, + ]) + ->create(); diff --git a/backend/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/backend/bootstrap/providers.php @@ -0,0 +1,5 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/backend/config/auth.php b/backend/config/auth.php new file mode 100644 index 0000000..79071c9 --- /dev/null +++ b/backend/config/auth.php @@ -0,0 +1,103 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'planners', + ], + + 'api' => [ + 'driver' => 'session', // or token if you ever need it + 'provider' => 'planners', + ], + ], + + 'providers' => [ + 'planners' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Planner::class, + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the amount of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/backend/config/cache.php b/backend/config/cache.php new file mode 100644 index 0000000..925f7d2 --- /dev/null +++ b/backend/config/cache.php @@ -0,0 +1,108 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), + +]; diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..94f07be --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => ['*'], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..125949e --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,173 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/backend/config/filesystems.php b/backend/config/filesystems.php new file mode 100644 index 0000000..b564035 --- /dev/null +++ b/backend/config/filesystems.php @@ -0,0 +1,77 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/backend/config/logging.php b/backend/config/logging.php new file mode 100644 index 0000000..8d94292 --- /dev/null +++ b/backend/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/backend/config/mail.php b/backend/config/mail.php new file mode 100644 index 0000000..756305b --- /dev/null +++ b/backend/config/mail.php @@ -0,0 +1,116 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + +]; diff --git a/backend/config/queue.php b/backend/config/queue.php new file mode 100644 index 0000000..116bd8d --- /dev/null +++ b/backend/config/queue.php @@ -0,0 +1,112 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..764a82f --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,83 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort() + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/config/services.php b/backend/config/services.php new file mode 100644 index 0000000..27a3617 --- /dev/null +++ b/backend/config/services.php @@ -0,0 +1,38 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'resend' => [ + 'key' => env('RESEND_KEY'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/backend/config/session.php b/backend/config/session.php new file mode 100644 index 0000000..7ca8b77 --- /dev/null +++ b/backend/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN', null), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/backend/database/.gitignore b/backend/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/backend/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/backend/database/factories/DishFactory.php b/backend/database/factories/DishFactory.php new file mode 100755 index 0000000..a949383 --- /dev/null +++ b/backend/database/factories/DishFactory.php @@ -0,0 +1,27 @@ + + */ +class DishFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => fake()->name, + ]; + } + + public function planner(Planner $planner): self + { + return $this->state(fn (array $attributes) => [ + 'planner_id' => $planner->id, + ]); + } +} diff --git a/backend/database/factories/MinimumRecurrenceFactory.php b/backend/database/factories/MinimumRecurrenceFactory.php new file mode 100644 index 0000000..cee95a8 --- /dev/null +++ b/backend/database/factories/MinimumRecurrenceFactory.php @@ -0,0 +1,26 @@ + + */ +class MinimumRecurrenceFactory extends Factory +{ + public function definition(): array + { + return [ + 'days' => fake()->randomElement(range(2, 30)), + ]; + } + + public function days(int $days): self + { + return $this->state(fn (array $attributes) => [ + 'days' => $days, + ]); + } +} diff --git a/backend/database/factories/PlannerFactory.php b/backend/database/factories/PlannerFactory.php new file mode 100644 index 0000000..5b60266 --- /dev/null +++ b/backend/database/factories/PlannerFactory.php @@ -0,0 +1,34 @@ + + */ +class PlannerFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/backend/database/factories/ScheduleFactory.php b/backend/database/factories/ScheduleFactory.php new file mode 100644 index 0000000..8ed5cbe --- /dev/null +++ b/backend/database/factories/ScheduleFactory.php @@ -0,0 +1,48 @@ + + */ +class ScheduleFactory extends Factory +{ + public function definition(): array + { + return [ + 'planner_id' => null, + 'date' => fake()->dateTimeBetween('-5 years', 'now')->format('Y-m-d'), + 'is_skipped' => false, + ]; + } + + public function planner(Planner $planner): self + { + return $this->state(fn (array $attributes) => [ + 'planner_id' => $planner->id, + ]); + } + + public function date(string|Carbon $date): self + { + if ($date instanceof Carbon) { + $date = $date->format('Y-m-d'); + } + + return $this->state(fn (array $attributes) => [ + 'date' => $date, + ]); + } + + public function skipped(): self + { + return $this->state(fn (array $attributes) => [ + 'is_skipped' => true, + ]); + } +} diff --git a/backend/database/factories/ScheduledUserDishFactory.php b/backend/database/factories/ScheduledUserDishFactory.php new file mode 100644 index 0000000..5dd263e --- /dev/null +++ b/backend/database/factories/ScheduledUserDishFactory.php @@ -0,0 +1,54 @@ + + */ +class ScheduledUserDishFactory extends Factory +{ + public function definition(): array + { + return [ + 'schedule_id' => null, + 'user_id' => null, + 'user_dish_id' => null, + 'is_skipped' => false, + ]; + } + + public function schedule(Schedule $schedule): self + { + return $this->state(fn (array $attributes) => [ + 'schedule_id' => $schedule->id, + ]); + } + + public function user(User $user): self + { + return $this->state(fn (array $attributes) => [ + 'user_id' => $user->id, + ]); + } + + public function userDish(UserDish $userDish): self + { + return $this->state(fn (array $attributes) => [ + 'user_dish_id' => $userDish->id, + 'user_id' => $userDish->user_id, + ]); + } + + public function skipped(): self + { + return $this->state(fn (array $attributes) => [ + 'is_skipped' => true, + ]); + } +} diff --git a/backend/database/factories/UserDishFactory.php b/backend/database/factories/UserDishFactory.php new file mode 100644 index 0000000..7c235ef --- /dev/null +++ b/backend/database/factories/UserDishFactory.php @@ -0,0 +1,36 @@ + + */ +class UserDishFactory extends Factory +{ + public function definition(): array + { + return [ + 'dish_id' => null, + 'user_id' => null, + ]; + } + + public function dish(Dish $dish): self + { + return $this->state(fn (array $attributes) => [ + 'dish_id' => $dish->id, + ]); + } + + public function user(User $user): self + { + return $this->state(fn (array $attributes) => [ + 'user_id' => $user->id, + ]); + } +} diff --git a/backend/database/factories/UserDishRecurrenceFactory.php b/backend/database/factories/UserDishRecurrenceFactory.php new file mode 100644 index 0000000..5a91226 --- /dev/null +++ b/backend/database/factories/UserDishRecurrenceFactory.php @@ -0,0 +1,38 @@ + + */ +class UserDishRecurrenceFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_dish_id' => null, + 'recurrence_id' => null, + 'recurrence_type' => null, + ]; + } + + public function userDish(UserDish $userDish): self + { + return $this->state(fn (array $attributes) => [ + 'user_dish_id' => $userDish->id, + ]); + } + + public function recurrence(RecurrenceInterface $recurrence): self + { + return $this->state(fn (array $attributes) => [ + 'recurrence_id' => $recurrence->id, + 'recurrence_type' => $recurrence::class, + ]); + } +} diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php new file mode 100644 index 0000000..2a2cdd9 --- /dev/null +++ b/backend/database/factories/UserFactory.php @@ -0,0 +1,30 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'planner_id' => null, + 'name' => fake()->name(), + ]; + } + + public function planner(Planner $planner): self + { + return $this->state(fn (array $attributes) => [ + 'planner_id' => $planner->id, + ]); + } +} diff --git a/backend/database/factories/WeeklyRecurrenceFactory.php b/backend/database/factories/WeeklyRecurrenceFactory.php new file mode 100644 index 0000000..3ec2116 --- /dev/null +++ b/backend/database/factories/WeeklyRecurrenceFactory.php @@ -0,0 +1,27 @@ + + */ +class WeeklyRecurrenceFactory extends Factory +{ + public function definition(): array + { + return [ + 'weekday' => fake()->randomElement(range(0, 7)), + ]; + } + + public function weekday(WeekdaysEnum $weekday): self + { + return $this->state(fn (array $attributes) => [ + 'weekday' => $weekday->value, + ]); + } +} diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100755 index 0000000..67773d3 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('planners'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100755 index 0000000..b9c106b --- /dev/null +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100755 index 0000000..425e705 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/backend/database/migrations/2025_01_18_004639_create_dishes_table.php b/backend/database/migrations/2025_01_18_004639_create_dishes_table.php new file mode 100755 index 0000000..044311e --- /dev/null +++ b/backend/database/migrations/2025_01_18_004639_create_dishes_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('dishes'); + } +}; diff --git a/backend/database/migrations/2025_02_02_130855_user_dishes.php b/backend/database/migrations/2025_02_02_130855_user_dishes.php new file mode 100755 index 0000000..4002abe --- /dev/null +++ b/backend/database/migrations/2025_02_02_130855_user_dishes.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('dish_id'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('dish_id')->references('id')->on('dishes')->onDelete('cascade'); + $table->unique(['user_id', 'dish_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_dishes'); + } +}; diff --git a/backend/database/migrations/2025_02_08_231219_create_schedules_table.php b/backend/database/migrations/2025_02_08_231219_create_schedules_table.php new file mode 100755 index 0000000..98dbb15 --- /dev/null +++ b/backend/database/migrations/2025_02_08_231219_create_schedules_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->boolean('is_skipped')->default(false); + + $table->unique(['planner_id', 'date']); + }); + + Schema::create('scheduled_user_dishes', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('schedule_id'); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('user_dish_id')->nullable(); + $table->boolean('is_skipped')->default(false); + $table->timestamps(); + + $table->foreign('schedule_id')->references('id')->on('schedules')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('user_dish_id')->references('id')->on('user_dishes')->onDelete('cascade'); + + $table->unique(['schedule_id', 'user_dish_id']); + $table->index('user_dish_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('scheduled_user_dishes'); + Schema::dropIfExists('schedules'); + } +}; diff --git a/backend/database/migrations/2025_03_03_204906_recurrence_types.php b/backend/database/migrations/2025_03_03_204906_recurrence_types.php new file mode 100755 index 0000000..39cd07c --- /dev/null +++ b/backend/database/migrations/2025_03_03_204906_recurrence_types.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('user_dish_id'); + $table->integer('recurrence_id'); + $table->string('recurrence_type'); + $table->timestamps(); + + $table->foreign('user_dish_id')->references('id')->on('user_dishes')->cascadeOnDelete(); + }); + + Schema::create('minimum_recurrences', function (Blueprint $table) { + $table->id(); + $table->integer('days'); // Minimum gap in days + $table->timestamps(); + }); + + Schema::create('weekly_recurrences', function (Blueprint $table) { + $table->id(); + $table->integer('weekday'); // 0 = Sunday, 6 = Saturday + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_dish_recurrences'); + } +}; diff --git a/backend/database/migrations/2025_04_19_001446_create_personal_access_tokens_table.php b/backend/database/migrations/2025_04_19_001446_create_personal_access_tokens_table.php new file mode 100755 index 0000000..b30c323 --- /dev/null +++ b/backend/database/migrations/2025_04_19_001446_create_personal_access_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/database/migrations/2025_04_19_195152_create_sessions_table.php b/backend/database/migrations/2025_04_19_195152_create_sessions_table.php new file mode 100755 index 0000000..7a84e3d --- /dev/null +++ b/backend/database/migrations/2025_04_19_195152_create_sessions_table.php @@ -0,0 +1,25 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..3a08996 --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,18 @@ +call(PlannersSeeder::class); + $this->call(UsersSeeder::class); + $this->call(DishesSeeder::class); + $this->call(ScheduleSeeder::class); + } +} diff --git a/backend/database/seeders/DishesSeeder.php b/backend/database/seeders/DishesSeeder.php new file mode 100644 index 0000000..4c91d47 --- /dev/null +++ b/backend/database/seeders/DishesSeeder.php @@ -0,0 +1,33 @@ +first()], + [$users->last()], + [$users->first(), $users->last()], + ]); + + $planner = Planner::all()->first() ?? Planner::factory()->create(); + + collect([ + 'lasagne', 'pizza', 'burger', 'fries', 'salad', 'sushi', 'pancakes', 'ice cream', 'spaghetti', 'mac and cheese', + 'steak', 'chicken', 'beef', 'pork', 'fish', 'chips', 'cake', + ])->map(fn (string $name) => Dish::factory() + ->create([ + 'planner_id' => $planner->id, + 'name' => $name, + ]) + )->each(fn (Dish $dish) => $dish->users()->attach($userOptions->random())); + } +} diff --git a/backend/database/seeders/PlannersSeeder.php b/backend/database/seeders/PlannersSeeder.php new file mode 100644 index 0000000..2d98cc8 --- /dev/null +++ b/backend/database/seeders/PlannersSeeder.php @@ -0,0 +1,25 @@ + 'Admin', + 'email' => 'admin@test.com', + 'password' => 'password' + ], + ])->each(fn (array $data) => Planner::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ])); + } +} diff --git a/backend/database/seeders/ScheduleSeeder.php b/backend/database/seeders/ScheduleSeeder.php new file mode 100755 index 0000000..82652c6 --- /dev/null +++ b/backend/database/seeders/ScheduleSeeder.php @@ -0,0 +1,57 @@ +upcoming(); + $this->history(); + } + + public function upcoming(): void + { + $start = Carbon::now()->startOfDay(); + $end = $start->copy()->addDays(14); + + $period = CarbonPeriod::create($start, '1 day', $end); + $this->createScheduleForPeriod($period); + } + + public function history(): void + { + $end = Carbon::now()->subDay()->startOfDay(); + $start = $end->copy()->subDays(14); + + $period = CarbonPeriod::create($start, '1 day', $end); + $this->createScheduleForPeriod($period); + } + + private function createScheduleForPeriod(CarbonPeriod $period): void + { + $planner = Planner::all()->first() ?? Planner::factory()->create(); + + collect($period) + ->each(fn (Carbon $date) => + User::query() + ->inRandomOrder() + ->get() + ->each(fn (User $user) => (new CreateScheduledUserDishAction()) + ->execute( + planner: $planner, + schedule: resolve(ScheduleRepository::class)->findOrCreate($planner, $date), + userDish: $user->userDishes->random(), + ) + ) + ); + } +} diff --git a/backend/database/seeders/UsersSeeder.php b/backend/database/seeders/UsersSeeder.php new file mode 100644 index 0000000..2217a9a --- /dev/null +++ b/backend/database/seeders/UsersSeeder.php @@ -0,0 +1,22 @@ +first() ?? Planner::factory()->create(); + + collect(['Melissa', 'Jochen']) + ->each(fn (string $name) => User::factory()->create([ + 'planner_id' => $planner->id, + 'name' => $name, + ])) + ; + } +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..f296bc9 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,52 @@ +services: + laravel.test: + build: + context: './vendor/laravel/sail/runtimes/8.4' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: 'sail-8.4/app' + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' + XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - mysql + mysql: + image: 'mysql/mysql-server:8.0' + ports: + - '${FORWARD_DB_PORT:-3306}:3306' + environment: + MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' + MYSQL_ROOT_HOST: '%' + MYSQL_DATABASE: '${DB_DATABASE}' + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + volumes: + - 'sail-mysql:/var/lib/mysql' + networks: + - sail + healthcheck: + test: + - CMD + - mysqladmin + - ping + - '-p${DB_PASSWORD}' + retries: 3 + timeout: 5s +networks: + sail: + driver: bridge +volumes: + sail-mysql: + driver: local diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..0d10472 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "autoprefixer": "^10.4.20", + "axios": "^1.7.4", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "vite": "^6.0" + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..24bb646 --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,38 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/postcss.config.js b/backend/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/backend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/backend/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..947d989 --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,17 @@ +handleRequest(Request::capture()); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/backend/resources/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..b9d609c --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,176 @@ + + + + + + + Laravel + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + + + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..28b43b3 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,16 @@ + 'api.', +], function () { + require __DIR__ . '/api/auth.php'; + + Route::middleware('auth:sanctum')->group(function () { + require __DIR__ . '/api/users.php'; + require __DIR__ . '/api/dishes.php'; + require __DIR__ . '/api/schedule.php'; + require __DIR__ . '/api/scheduledUserDishes.php'; + }); +}); diff --git a/backend/routes/api/auth.php b/backend/routes/api/auth.php new file mode 100644 index 0000000..bd944e0 --- /dev/null +++ b/backend/routes/api/auth.php @@ -0,0 +1,21 @@ + 'auth.', + 'controller' => PlannerAuthController::class, + 'prefix' => 'auth', +], function () { + Route::post('/login', 'login')->name('login'); + Route::post('/logout', 'logout')->name('logout'); + Route::post('/register', 'register')->name('register'); + + Route::middleware('auth:sanctum') + ->get('/me', fn (Request $request) => response() + ->json($request->user()) + )->name('me'); +}); + diff --git a/backend/routes/api/dishes.php b/backend/routes/api/dishes.php new file mode 100644 index 0000000..2d43513 --- /dev/null +++ b/backend/routes/api/dishes.php @@ -0,0 +1,20 @@ + 'dishes.', + 'controller' => DishController::class, + 'prefix' => 'dishes', +], function () { + Route::get('/', 'index')->name('index'); + Route::post('/', 'store')->name('store'); + Route::get('/{dish}', 'show')->name('show'); + Route::put('/{dish}', 'update')->name('update'); + Route::delete('/{dish}', 'destroy')->name('destroy'); + + Route::post('/{dish}/users/sync', 'syncUsers')->name('users.sync'); + Route::post('/{dish}/users/add', 'addUsers')->name('users.add'); + Route::post('/{dish}/users/remove', 'removeUsers')->name('users.remove'); +}); diff --git a/backend/routes/api/schedule.php b/backend/routes/api/schedule.php new file mode 100755 index 0000000..39e0b8e --- /dev/null +++ b/backend/routes/api/schedule.php @@ -0,0 +1,31 @@ + 'schedule.', + 'controller' => ScheduleController::class, + 'prefix' => 'schedule', +], function () { + Route::get('/', 'index')->name('index'); + Route::get('/{date}', 'show') + ->where('date', '\d{4}-\d{2}-\d{2}') + ->name('show'); + + Route::put('/{date}', 'update') + ->where('date', '\d{4}-\d{2}-\d{2}') + ->name('update'); + + Route::post('/generate', 'generate') + ->name('generate'); + + Route::post('/{date}/user-dishes', ScheduleUserDishController::class) + ->name('user-dish.update'); +}); diff --git a/backend/routes/api/scheduledUserDishes.php b/backend/routes/api/scheduledUserDishes.php new file mode 100755 index 0000000..1a84f8f --- /dev/null +++ b/backend/routes/api/scheduledUserDishes.php @@ -0,0 +1,15 @@ + 'scheduled-user-dishes.', + 'controller' => ScheduledUserDishController::class, + 'prefix' => 'scheduled-user-dishes', +], function () { + Route::post('/', 'create')->name('store'); + Route::get('/{scheduledUserDish}', 'read')->name('show'); + Route::put('/{scheduledUserDish}', 'update')->name('update'); + Route::delete('/{scheduledUserDish}', 'delete')->name('destroy'); +}); diff --git a/backend/routes/api/users.php b/backend/routes/api/users.php new file mode 100644 index 0000000..c75b3dc --- /dev/null +++ b/backend/routes/api/users.php @@ -0,0 +1,40 @@ + 'users.', + 'prefix' => 'users', +], function () { + Route::get('/', ListUsersController::class)->name('index'); + Route::get('/{user}', [UserController::class, 'show'])->name('show'); + Route::post('/', [UserController::class, 'create'])->name('create'); + Route::put('/{user}', [UserController::class, 'update'])->name('update'); + Route::delete('/{user}', [UserController::class, 'delete'])->name('delete'); + + Route::group([ + 'as' => 'dishes.', + 'controller' => UserDishController::class, + 'prefix' => '{user}/dishes', + ], function () { + Route::get('/', 'index')->name('index'); + Route::get('/{dish}', 'show')->name('show'); + Route::post('/{dish}', 'store')->name('store'); + Route::delete('/{dish}', 'destroy')->name('destroy'); + + Route::group([ + 'as' => 'recurrences.', + 'controller' => UserDishRecurrenceController::class, + 'prefix' => '{dish}/recurrences', + ], function () { + Route::post('/', 'store')->name('store'); + }); + }); +}); + +Route::get('/user-dishes', ListUserDishesController::class)->name('user-dishes.index'); diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..eff2ed2 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote')->hourly(); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,7 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + if (!Auth::attempt($credentials)) { + return response()->json([ + 'message' => 'The provided credentials are incorrect.', + ], 401); + } + + $planner = auth()->user(); + + // Issue token + $token = $planner->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + ]); + } + + public function logout(Request $request): JsonResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return response()->json(['message' => 'Logged out']); + } + + public function register(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:planners,email', + 'password' => 'required|string|min:8|confirmed', + ]); + + $planner = resolve(CreatePlannerAction::class)->execute($data); + + return response()->json([ + 'message' => 'Planner registered successfully.', + 'planner' => $planner, + ], 201); + } +} diff --git a/backend/src/DishPlanner/Auth/Exceptions/InvalidPlannerException.php b/backend/src/DishPlanner/Auth/Exceptions/InvalidPlannerException.php new file mode 100644 index 0000000..0252cd3 --- /dev/null +++ b/backend/src/DishPlanner/Auth/Exceptions/InvalidPlannerException.php @@ -0,0 +1,10 @@ +users()->attach($userIds); + + return $dish; + } +} diff --git a/backend/src/DishPlanner/Dish/Actions/CreateDishAction.php b/backend/src/DishPlanner/Dish/Actions/CreateDishAction.php new file mode 100644 index 0000000..4897383 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Actions/CreateDishAction.php @@ -0,0 +1,21 @@ +user(); + + return Dish::create([ + 'planner_id' => $planner->id, + 'name' => Arr::get($data, 'name'), + ]); + } +} diff --git a/backend/src/DishPlanner/Dish/Actions/DeleteDishAction.php b/backend/src/DishPlanner/Dish/Actions/DeleteDishAction.php new file mode 100644 index 0000000..8a52923 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Actions/DeleteDishAction.php @@ -0,0 +1,15 @@ +delete(); + + return true; + } +} diff --git a/backend/src/DishPlanner/Dish/Actions/RemoveUsersFromDishAction.php b/backend/src/DishPlanner/Dish/Actions/RemoveUsersFromDishAction.php new file mode 100644 index 0000000..faa0b54 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Actions/RemoveUsersFromDishAction.php @@ -0,0 +1,15 @@ +users()->detach($userIds); + + return $dish; + } +} diff --git a/backend/src/DishPlanner/Dish/Actions/SyncUsersAction.php b/backend/src/DishPlanner/Dish/Actions/SyncUsersAction.php new file mode 100644 index 0000000..b7c746b --- /dev/null +++ b/backend/src/DishPlanner/Dish/Actions/SyncUsersAction.php @@ -0,0 +1,15 @@ +users()->sync($userIds); + + return $dish; + } +} diff --git a/backend/src/DishPlanner/Dish/Actions/UpdateDishAction.php b/backend/src/DishPlanner/Dish/Actions/UpdateDishAction.php new file mode 100644 index 0000000..2eacb31 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Actions/UpdateDishAction.php @@ -0,0 +1,16 @@ +update(Arr::only($data, ['name'])); + + return $dish->refresh(); + } +} diff --git a/backend/src/DishPlanner/Dish/Controllers/DishController.php b/backend/src/DishPlanner/Dish/Controllers/DishController.php new file mode 100644 index 0000000..02e183c --- /dev/null +++ b/backend/src/DishPlanner/Dish/Controllers/DishController.php @@ -0,0 +1,86 @@ +success(['dishes' => DishResource::collection(Dish::all())]); + } + + public function store(StoreDishRequest $request): JsonResponse + { + $dish = (new CreateDishAction())->execute($request->validated()); + + return $this->success(['dish' => new DishResource($dish)]); + } + + public function show(Dish $dish): JsonResponse + { + Gate::authorize('view', $dish); + + return $this->success(['dish' => new DishResource($dish)]); + } + + public function update(UpdateDishRequest $request, Dish $dish): JsonResponse + { + Gate::authorize('update', $dish); + + $dish = (new UpdateDishAction())->execute($dish, $request->validated()); + + return $this->success(['dish' => new DishResource($dish)]); + } + + public function destroy(Dish $dish): JsonResponse + { + Gate::authorize('delete', $dish); + + (new DeleteDishAction())->execute($dish); + + return $this->success(null); + } + + public function syncUsers(SyncUsersRequest $request, Dish $dish): JsonResponse + { + (new SyncUsersAction())->execute($dish, Arr::get($request->validated(), 'users', [])); + + return $this->success(['dish' => new DishResource($dish->refresh())]); + } + + public function addUsers(AddUsersToDishRequest $request, Dish $dish): JsonResponse + { + Gate::authorize('update', $dish); + + (new AddUsersToDishAction())->execute($dish, Arr::get($request->validated(), 'users', [])); + + return $this->success(['dish' => new DishResource($dish->refresh())]); + } + + public function removeUsers(RemoveUsersFromDishRequest $request, Dish $dish): JsonResponse + { + (new RemoveUsersFromDishAction())->execute($dish, Arr::get($request->validated(), 'users', [])); + + return $this->success(['dish' => new DishResource($dish->refresh())]); + } +} diff --git a/backend/src/DishPlanner/Dish/Exceptions/DishNotFoundException.php b/backend/src/DishPlanner/Dish/Exceptions/DishNotFoundException.php new file mode 100644 index 0000000..1238f96 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Exceptions/DishNotFoundException.php @@ -0,0 +1,10 @@ +id === $dish->planner_id; + } + + public function create(Planner $planner): bool + { + return false; + } + + public function update(Planner $planner, Dish $dish): bool + { + return $this->view($planner, $dish); + } + + public function delete(Planner $planner, Dish $dish): bool + { + return $this->view($planner, $dish); + } + + public function restore(Planner $planner, Dish $dish): bool + { + return false; + } + + public function forceDelete(Planner $planner, Dish $dish): bool + { + return false; + } +} diff --git a/backend/src/DishPlanner/Dish/Repositories/DishRepository.php b/backend/src/DishPlanner/Dish/Repositories/DishRepository.php new file mode 100644 index 0000000..713354f --- /dev/null +++ b/backend/src/DishPlanner/Dish/Repositories/DishRepository.php @@ -0,0 +1,14 @@ +dishes()->inRandomOrder()->first(); + } +} diff --git a/backend/src/DishPlanner/Dish/Requests/AddUsersToDishRequest.php b/backend/src/DishPlanner/Dish/Requests/AddUsersToDishRequest.php new file mode 100644 index 0000000..159582f --- /dev/null +++ b/backend/src/DishPlanner/Dish/Requests/AddUsersToDishRequest.php @@ -0,0 +1,16 @@ + ['required', 'array'], + 'users.*' => ['required', 'integer', 'exists:users,id'], + ]; + } +} diff --git a/backend/src/DishPlanner/Dish/Requests/RemoveUsersFromDishRequest.php b/backend/src/DishPlanner/Dish/Requests/RemoveUsersFromDishRequest.php new file mode 100644 index 0000000..f48ad2d --- /dev/null +++ b/backend/src/DishPlanner/Dish/Requests/RemoveUsersFromDishRequest.php @@ -0,0 +1,16 @@ + ['required', 'array'], + 'users.*' => ['required', 'integer', 'exists:users,id'], + ]; + } +} diff --git a/backend/src/DishPlanner/Dish/Requests/StoreDishRequest.php b/backend/src/DishPlanner/Dish/Requests/StoreDishRequest.php new file mode 100755 index 0000000..6698146 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Requests/StoreDishRequest.php @@ -0,0 +1,15 @@ + ['required', 'string', 'min:3', 'max:128'], + ]; + } +} diff --git a/backend/src/DishPlanner/Dish/Requests/SyncUsersRequest.php b/backend/src/DishPlanner/Dish/Requests/SyncUsersRequest.php new file mode 100644 index 0000000..0d9c9ba --- /dev/null +++ b/backend/src/DishPlanner/Dish/Requests/SyncUsersRequest.php @@ -0,0 +1,16 @@ + ['required', 'array'], + 'users.*' => ['required', 'integer', 'exists:users,id'], + ]; + } +} diff --git a/backend/src/DishPlanner/Dish/Requests/UpdateDishRequest.php b/backend/src/DishPlanner/Dish/Requests/UpdateDishRequest.php new file mode 100755 index 0000000..acf1f6f --- /dev/null +++ b/backend/src/DishPlanner/Dish/Requests/UpdateDishRequest.php @@ -0,0 +1,15 @@ + 'required|string|max:255', + ]; + } +} diff --git a/backend/src/DishPlanner/Dish/Resources/DishResource.php b/backend/src/DishPlanner/Dish/Resources/DishResource.php new file mode 100755 index 0000000..af69c71 --- /dev/null +++ b/backend/src/DishPlanner/Dish/Resources/DishResource.php @@ -0,0 +1,25 @@ + $this->id, + 'planner_id' => $this->planner_id, + 'name' => $this->name, + 'users' => $this->userDishes + ->map(fn (UserDish $userDish) => $userDish->user) + ->map(fn (User $user) => new UserResource($user)) + ->toArray(), + ]; + } +} diff --git a/backend/src/DishPlanner/Planner/Actions/CreatePlannerAction.php b/backend/src/DishPlanner/Planner/Actions/CreatePlannerAction.php new file mode 100644 index 0000000..a3d244f --- /dev/null +++ b/backend/src/DishPlanner/Planner/Actions/CreatePlannerAction.php @@ -0,0 +1,23 @@ + $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + + resolve(GenerateSchedulesForUserAction::class)->execute($planner); + + return $planner; + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForDateAction.php b/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForDateAction.php new file mode 100644 index 0000000..1375ad5 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForDateAction.php @@ -0,0 +1,28 @@ +reject(fn($user) => $schedule + ->scheduledUserDishes + ->map(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish?->user) + ->filter() + ->contains($user) + ) + ->each(fn (User $user) => $user->userDishes->isNotEmpty() && ScheduledUserDish::create([ + 'schedule_id' => $schedule->id, + 'user_dish_id' => $user->userDishes->random()->id, + 'user_id' => $user->id, + ])); + + return $schedule->refresh(); + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForPeriodAction.php b/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForPeriodAction.php new file mode 100644 index 0000000..9891af2 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/DraftScheduleForPeriodAction.php @@ -0,0 +1,30 @@ +each(function (Carbon $date) use ($planner) { + $schedule = Schedule::query() + ->where('date', $date->format('Y-m-d')) + ->first(); + + if (is_null($schedule)) { + $schedule = Schedule::create([ + 'planner_id' => $planner->id, + 'date' => $date, + ]); + } + + return resolve(DraftScheduleForDateAction::class)->execute($schedule); + }); + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/GenerateScheduleForPeriodAction.php b/backend/src/DishPlanner/Schedule/Actions/GenerateScheduleForPeriodAction.php new file mode 100644 index 0000000..1e25791 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/GenerateScheduleForPeriodAction.php @@ -0,0 +1,32 @@ +=', Carbon::now())->delete(); + } + + $startDate = Carbon::now(); + $endDate = Carbon::now()->addDays(13); + $period = CarbonPeriod::create($startDate, $endDate); + + /** @var ScheduleRepository $scheduleRepository */ + $scheduleRepository = resolve(ScheduleRepository::class); + + foreach ($period as $date) { + $schedule = $scheduleRepository->findOrCreate($planner, $date); + + resolve(RegenerateScheduleDayAction::class)->execute($planner, $schedule, $overwrite); + } + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/GenerateSchedulesForUserAction.php b/backend/src/DishPlanner/Schedule/Actions/GenerateSchedulesForUserAction.php new file mode 100644 index 0000000..6b188d1 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/GenerateSchedulesForUserAction.php @@ -0,0 +1,36 @@ +copy()->addYears(2); + + $currentDate = $startDate->copy(); + + while ($currentDate->lte($endDate)) { + $exists = Schedule::where('planner_id', $planner->id) + ->where('date', $currentDate->toDateString()) + ->exists(); + + if (! $exists) { + Schedule::create([ + 'planner_id' => $planner->id, + 'date' => $currentDate->toDateString(), + 'is_skipped' => false, + ]); + } + + $currentDate->addDay(); + } + + return 0; + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayAction.php b/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayAction.php new file mode 100644 index 0000000..e4416fe --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayAction.php @@ -0,0 +1,18 @@ +each(fn (User $user) => resolve(RegenerateScheduleDayForUserAction::class) + ->execute($planner, $schedule, $user, $overwrite) + ); + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayForUserAction.php b/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayForUserAction.php new file mode 100644 index 0000000..a27de32 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/RegenerateScheduleDayForUserAction.php @@ -0,0 +1,48 @@ +findForScheduleAndUser($schedule, $user); + + $fixedUserDish = resolve(UserDishRepository::class)->findForUserByFixedRecurrenceOnDay($user, $schedule->date->dayOfWeek); + + /** @var CreateScheduledUserDishAction $createAction */ + $createAction = resolve(CreateScheduledUserDishAction::class); + + if (is_null($scheduledUserDish)) { + return $createAction->execute( + planner: $planner, + schedule: $schedule, + userDish: $fixedUserDish ?? $user->userDishes->random(), + ); + } + + if (!$overwrite && $scheduledUserDish->userDish) { + return $scheduledUserDish; + } + + if ($fixedUserDish && $scheduledUserDish->user_dish_id === $fixedUserDish->id) { + return $scheduledUserDish; + } + + $scheduledUserDish->delete(); + + return $createAction->execute( + planner: $planner, + schedule: $schedule, + userDish: $fixedUserDish ?? $user->userDishes->random(), + ); + } +} diff --git a/backend/src/DishPlanner/Schedule/Actions/UpdateScheduleAction.php b/backend/src/DishPlanner/Schedule/Actions/UpdateScheduleAction.php new file mode 100644 index 0000000..db06acf --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Actions/UpdateScheduleAction.php @@ -0,0 +1,24 @@ +update([ + 'is_skipped' => $isSkipped, + ]); + + $schedule + ->scheduledUserDishes + ->each(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish + ->update(['user_dish_id' => null]) + ); + + return $schedule->refresh(); + } +} diff --git a/backend/src/DishPlanner/Schedule/Controllers/ScheduleController.php b/backend/src/DishPlanner/Schedule/Controllers/ScheduleController.php new file mode 100644 index 0000000..5dd9f6c --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Controllers/ScheduleController.php @@ -0,0 +1,90 @@ +user(); + + $startDate = $request->get('start', now()->format('Y-m-d')); + $endDate = $request->get('end', now()->addWeeks(2)->format('Y-m-d')); + + $schedule = Schedule::query() + ->where('planner_id', $planner->id) + ->where('date', '>=', $startDate) + ->where('date', '<=', $endDate) + ->orderBy('date', 'asc')->get()->values(); + + return $this->success([ + 'schedule' => ScheduleResource::collection($schedule), + ]); + } + + public function show(Carbon $date): JsonResponse + { + /** @var Planner $planner */ + $planner = auth()->user(); + + /** @var ScheduleRepository $scheduleRepository */ + $scheduleRepository = resolve(ScheduleRepository::class); + $schedule = $scheduleRepository->findOrCreate($planner, $date); + + Gate::authorize('view', $schedule); + + return $this->success([ + 'schedule' => new ScheduleResource($schedule), + ]); + } + + public function update(UpdateScheduleRequest $request, Carbon $date): JsonResponse + { + /** @var Planner $planner */ + $planner = auth()->user(); + + /** @var ScheduleRepository $scheduleRepository */ + $scheduleRepository = resolve(ScheduleRepository::class); + $schedule = $scheduleRepository->findOrCreate($planner, $date); + + Gate::authorize('update', $schedule); + + resolve(UpdateScheduleAction::class)->execute( + schedule: $schedule, + isSkipped: $request->is_skipped + ); + + return $this->success([ + 'schedule' => new ScheduleResource($schedule->refresh()), + ]); + } + + public function generate(GenerateScheduleRequest $request): JsonResponse + { + /** @var Planner $planner */ + $planner = auth()->user(); + + (new GenerateScheduleForPeriodAction())->execute($planner, $request->get('overwrite', false)); + + return $this->success(null); + } +} diff --git a/backend/src/DishPlanner/Schedule/Controllers/ScheduleUserDishController.php b/backend/src/DishPlanner/Schedule/Controllers/ScheduleUserDishController.php new file mode 100644 index 0000000..f94082a --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Controllers/ScheduleUserDishController.php @@ -0,0 +1,68 @@ +user(); + + $skipped = $request->get('skipped', false); + $userId = $request->get('user_id'); + + $user = User::findOrFail($userId); + + /** @var ScheduleRepository $scheduleRepository */ + $scheduleRepository = resolve(ScheduleRepository::class); + $schedule = $scheduleRepository->findOrCreate($planner, $date); + + $userDishId = $request->get('user_dish_id'); + + $scheduledUserDish = $schedule + ->scheduledUserDishes() + ->forUser($user->id) + ->first(); + + if (! $scheduledUserDish) { + $scheduledUserDish = new ScheduledUserDish(); + } + + abort_if( + boolean: $userDishId === null && $skipped === false, + code: 400, + message: 'Skipped is required to be true if no user_dish_id is provided.' + ); + + $userDish = UserDish::find($userDishId); + + if ($userDish) { + abort_if($userDish->user->id !== $user->id, 400, 'User does not match.'); + } + + $scheduledUserDish->fill([ + 'schedule_id' => $schedule->id, + 'user_id' => $user->id, + 'user_dish_id' => $userDish?->id, + 'is_skipped' => is_null($userDish) && $skipped, + ]); + + $scheduledUserDish->save(); + + return $this->success([ + 'schedule' => new ScheduleResource($schedule->refresh()), + ]); + } +} diff --git a/backend/src/DishPlanner/Schedule/Policies/SchedulePolicy.php b/backend/src/DishPlanner/Schedule/Policies/SchedulePolicy.php new file mode 100644 index 0000000..b8352d1 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Policies/SchedulePolicy.php @@ -0,0 +1,65 @@ +planner_id === $planner->id; + } + + /** + * Determine whether the user can create models. + */ + public function create(Planner $planner): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(Planner $planner, Schedule $schedule): bool + { + return $this->view($planner, $schedule); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Planner $planner, Schedule $schedule): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Planner $planner, Schedule $schedule): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Planner $planner, Schedule $schedule): bool + { + return false; + } +} diff --git a/backend/src/DishPlanner/Schedule/Repositories/ScheduleRepository.php b/backend/src/DishPlanner/Schedule/Repositories/ScheduleRepository.php new file mode 100644 index 0000000..8c0cca8 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Repositories/ScheduleRepository.php @@ -0,0 +1,26 @@ +id) + ->where('date', $date->format('Y-m-d')) + ->first(); + + if (is_null($schedule)) { + $schedule = Schedule::create([ + 'planner_id' => $planner->id, + 'date' => $date->format('Y-m-d'), + ]); + } + + return $schedule; + } +} diff --git a/backend/src/DishPlanner/Schedule/Requests/CreateScheduleRequest.php b/backend/src/DishPlanner/Schedule/Requests/CreateScheduleRequest.php new file mode 100644 index 0000000..30d6ebe --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Requests/CreateScheduleRequest.php @@ -0,0 +1,21 @@ + 'required|exists:user_dishes,id', + 'date' => 'required|date|date_format:Y-m-d|after:today', + ]; + } +} diff --git a/backend/src/DishPlanner/Schedule/Requests/GenerateScheduleRequest.php b/backend/src/DishPlanner/Schedule/Requests/GenerateScheduleRequest.php new file mode 100644 index 0000000..e48901e --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Requests/GenerateScheduleRequest.php @@ -0,0 +1,15 @@ + ['sometimes', 'boolean'], + ]; + } +} diff --git a/backend/src/DishPlanner/Schedule/Requests/ScheduleUserDishRequest.php b/backend/src/DishPlanner/Schedule/Requests/ScheduleUserDishRequest.php new file mode 100644 index 0000000..94f845a --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Requests/ScheduleUserDishRequest.php @@ -0,0 +1,21 @@ + [ + 'required_without:skipped', + 'exists:user_dishes,id', + 'nullable' + ], + 'user_id' => ['required', 'exists:users,id'], + 'skipped' => ['required_if:user_dish_id,null', 'boolean'], + ]; + } +} diff --git a/backend/src/DishPlanner/Schedule/Requests/UpdateScheduleRequest.php b/backend/src/DishPlanner/Schedule/Requests/UpdateScheduleRequest.php new file mode 100755 index 0000000..895515e --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Requests/UpdateScheduleRequest.php @@ -0,0 +1,18 @@ + ['required', 'boolean'], + ]; + } +} diff --git a/backend/src/DishPlanner/Schedule/Resources/ScheduleResource.php b/backend/src/DishPlanner/Schedule/Resources/ScheduleResource.php new file mode 100755 index 0000000..dde8164 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Resources/ScheduleResource.php @@ -0,0 +1,44 @@ + $scheduledUserDishes + */ +class ScheduleResource extends JsonResource +{ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'date' => $this->date->format('Y-m-d'), + 'is_skipped' => $this->is_skipped, + 'scheduled_user_dishes' => $this->scheduledUserDishes(), + ]; + } + + private function scheduledUserDishes(): array + { + return $this->scheduledUserDishes + ->map(fn (ScheduledUserDish $scheduledUserDish) => [ + 'id' => $scheduledUserDish->id, + 'user' => [ + 'id' => $scheduledUserDish->user->id, + 'name' => $scheduledUserDish->user->name, + ], + 'skipped' => $scheduledUserDish->is_skipped, + 'user_dish' => new UserDishResource($scheduledUserDish->userDish), + ]) + ->toArray(); + } +} diff --git a/backend/src/DishPlanner/Schedule/Services/ScheduleGenerator.php b/backend/src/DishPlanner/Schedule/Services/ScheduleGenerator.php new file mode 100644 index 0000000..5e86f48 --- /dev/null +++ b/backend/src/DishPlanner/Schedule/Services/ScheduleGenerator.php @@ -0,0 +1,42 @@ +startOfDay()->addWeeks(2)); + + /** @var ScheduleRepository $scheduleRepository */ + $scheduleRepository = resolve(ScheduleRepository::class); + + /** @var UserDishRepository $userDishRepository */ + $userDishRepository = resolve(UserDishRepository::class); + + foreach ($period as $date) { + $users->each(function (User $user) use ($date, $planner, $scheduleRepository, $userDishRepository) { + $schedule = $scheduleRepository->findOrCreate($planner, $date); + + (new CreateScheduledUserDishAction())->execute( + planner: $planner, + schedule: $schedule, + userDish: $userDishRepository->getRandomForDate($user, $date) + ); + }); + } + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Actions/CreateScheduledUserDishAction.php b/backend/src/DishPlanner/ScheduledUserDish/Actions/CreateScheduledUserDishAction.php new file mode 100644 index 0000000..9ab9994 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Actions/CreateScheduledUserDishAction.php @@ -0,0 +1,28 @@ +dish->planner_id !== $planner->id || $userDish->user->planner_id !== $planner->id) { + throw new InvalidPlannerException(); + } + + return ScheduledUserDish::create([ + 'schedule_id' => $schedule->id, + 'user_dish_id' => $userDish->id, + 'user_id' => $userDish->user_id, + ]); + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Actions/DeleteScheduledUserDishAction.php b/backend/src/DishPlanner/ScheduledUserDish/Actions/DeleteScheduledUserDishAction.php new file mode 100644 index 0000000..6789ef8 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Actions/DeleteScheduledUserDishAction.php @@ -0,0 +1,15 @@ +delete(); + + return true; + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Actions/UpdateScheduledUserDishAction.php b/backend/src/DishPlanner/ScheduledUserDish/Actions/UpdateScheduledUserDishAction.php new file mode 100644 index 0000000..e13c5f8 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Actions/UpdateScheduledUserDishAction.php @@ -0,0 +1,20 @@ +is_skipped = true; + } + + $scheduledUserDish->user_dish_id = $userDish?->id; + $scheduledUserDish->save(); + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Controllers/ScheduledUserDishController.php b/backend/src/DishPlanner/ScheduledUserDish/Controllers/ScheduledUserDishController.php new file mode 100644 index 0000000..db4b912 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Controllers/ScheduledUserDishController.php @@ -0,0 +1,85 @@ +user_dish_id); + + Gate::authorize('assign', $userDish); + + $date = Carbon::createFromFormat('Y-m-d', $request->date); + + /** @var Planner $planner */ + $planner = auth()->user(); + + $schedule = resolve(ScheduleRepository::class)->findOrCreate($planner, $date); + + try { + $scheduledUserDish = (new CreateScheduledUserDishAction())->execute( + planner: $planner, + schedule: $schedule, + userDish: $userDish, + ); + } catch (InvalidPlannerException $e) { + return $this->error($e->getMessage(), 404); + } + + return $this->success([ + 'scheduled_user_dish' => new ScheduledUserDishResource($scheduledUserDish) + ]); + } + + public function read(ScheduledUserDish $scheduledUserDish): JsonResponse + { + Gate::authorize('view', $scheduledUserDish); + + return $this->success([ + 'scheduled_user_dish' => new ScheduledUserDishResource($scheduledUserDish->refresh()), + ]); + } + + public function update(UpdateScheduledUserDishRequest $request, ScheduledUserDish $scheduledUserDish): JsonResponse + { + Gate::authorize('update', $scheduledUserDish); + + (new UpdateScheduledUserDishAction())->execute( + scheduledUserDish: $scheduledUserDish, + userDish: UserDish::find($request->user_dish_id), + isSkipped: $request->is_skipped ?? null, + ); + + return $this->success([ + 'scheduled_user_dish' => new ScheduledUserDishResource($scheduledUserDish->refresh()), + ]); + } + + public function delete(ScheduledUserDish $scheduledUserDish): JsonResponse + { + Gate::authorize('delete', $scheduledUserDish); + + (new DeleteScheduledUserDishAction())->execute($scheduledUserDish); + + return $this->success(null); + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Policies/ScheduledUserDishPolicy.php b/backend/src/DishPlanner/ScheduledUserDish/Policies/ScheduledUserDishPolicy.php new file mode 100644 index 0000000..dafe218 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Policies/ScheduledUserDishPolicy.php @@ -0,0 +1,67 @@ +exists; + } + + /** + * Determine whether the user can view the model. + */ + public function view(Planner $planner, ScheduledUserDish $scheduledUserDish): bool + { + return Gate::forUser($planner)->allows('view', $scheduledUserDish->userDish); + } + + /** + * Determine whether the user can create models. + */ + public function create(Planner $planner): bool + { + return $this->viewAny($planner); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Planner $planner, ScheduledUserDish $scheduledUserDish): bool + { + return $this->view($planner, $scheduledUserDish); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Planner $planner, ScheduledUserDish $scheduledUserDish): bool + { + return Gate::forUser($planner)->allows('delete', $scheduledUserDish->userDish); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Planner $planner, ScheduledUserDish $scheduledUserDish): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Planner $planner, ScheduledUserDish $scheduledUserDish): bool + { + return false; + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Repositories/ScheduledUserDishRepository.php b/backend/src/DishPlanner/ScheduledUserDish/Repositories/ScheduledUserDishRepository.php new file mode 100644 index 0000000..fa7fb26 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Repositories/ScheduledUserDishRepository.php @@ -0,0 +1,18 @@ +where('schedule_id', $schedule->id) + ->whereHas('userDish', fn ($query) => $query->where('user_id', $user->id)) + ->first(); + } +} diff --git a/backend/src/DishPlanner/ScheduledUserDish/Requests/UpdateScheduledUserDishRequest.php b/backend/src/DishPlanner/ScheduledUserDish/Requests/UpdateScheduledUserDishRequest.php new file mode 100644 index 0000000..506d311 --- /dev/null +++ b/backend/src/DishPlanner/ScheduledUserDish/Requests/UpdateScheduledUserDishRequest.php @@ -0,0 +1,19 @@ + ['nullable', 'exists:user_dishes,id', 'required_unless:is_skipped,true'], + 'is_skipped' => ['nullable', 'boolean'], + ]; + } +} diff --git a/backend/src/DishPlanner/User/Actions/CreateUserAction.php b/backend/src/DishPlanner/User/Actions/CreateUserAction.php new file mode 100644 index 0000000..f54e814 --- /dev/null +++ b/backend/src/DishPlanner/User/Actions/CreateUserAction.php @@ -0,0 +1,17 @@ + $planner->id, + 'name' => $name, + ]); + } +} diff --git a/backend/src/DishPlanner/User/Actions/DeleteUserAction.php b/backend/src/DishPlanner/User/Actions/DeleteUserAction.php new file mode 100644 index 0000000..a18a120 --- /dev/null +++ b/backend/src/DishPlanner/User/Actions/DeleteUserAction.php @@ -0,0 +1,17 @@ +dishes()->delete(); + $user->delete(); + + return true; + } +} diff --git a/backend/src/DishPlanner/User/Actions/DeleteUserDishAction.php b/backend/src/DishPlanner/User/Actions/DeleteUserDishAction.php new file mode 100644 index 0000000..038acd0 --- /dev/null +++ b/backend/src/DishPlanner/User/Actions/DeleteUserDishAction.php @@ -0,0 +1,21 @@ +id)->where('dish_id', $dish->id)->first(); + + if (! $userDish) { + return; + } + + $userDish->delete(); + } +} diff --git a/backend/src/DishPlanner/User/Actions/UpdateUserAction.php b/backend/src/DishPlanner/User/Actions/UpdateUserAction.php new file mode 100644 index 0000000..c59c77c --- /dev/null +++ b/backend/src/DishPlanner/User/Actions/UpdateUserAction.php @@ -0,0 +1,18 @@ +update([ + 'name' => $name, + ]); + + return $user->refresh(); + } +} diff --git a/backend/src/DishPlanner/User/Controllers/ListUsersController.php b/backend/src/DishPlanner/User/Controllers/ListUsersController.php new file mode 100644 index 0000000..62835d3 --- /dev/null +++ b/backend/src/DishPlanner/User/Controllers/ListUsersController.php @@ -0,0 +1,20 @@ +success(['users' => UserWithUserDishesResource::collection(User::all()->sortBy('id'))]); + } +} diff --git a/backend/src/DishPlanner/User/Controllers/UserController.php b/backend/src/DishPlanner/User/Controllers/UserController.php new file mode 100644 index 0000000..9890708 --- /dev/null +++ b/backend/src/DishPlanner/User/Controllers/UserController.php @@ -0,0 +1,60 @@ +success(['user' => new UserResource($user)]); + } + + public function create(CreateUserRequest $request): JsonResponse + { + /** @var Planner $planner */ + $planner = auth()->user(); + + Gate::authorize('create', User::class); + + $requestData = $request->validated(); + + $user = (new CreateUserAction()) + ->execute($planner, Arr::get($requestData, 'name')); + + return $this->success(['user' => new UserResource($user)]); + } + + public function update(UpdateUserRequest $request, User $user): JsonResponse + { + Gate::authorize('update', $user); + + $user = (new UpdateUserAction()) + ->execute($user, Arr::get($request->validated(), 'name')); + + return $this->success(['user' => new UserResource($user)]); + } + + public function delete(User $user): JsonResponse + { + Gate::authorize('delete', $user); + + (new DeleteUserAction())->execute($user); + + return $this->success(null, 201); + } +} diff --git a/backend/src/DishPlanner/User/Policies/UserPolicy.php b/backend/src/DishPlanner/User/Policies/UserPolicy.php new file mode 100644 index 0000000..6f78c07 --- /dev/null +++ b/backend/src/DishPlanner/User/Policies/UserPolicy.php @@ -0,0 +1,65 @@ +exists; + } + + /** + * Determine whether the user can view the model. + */ + public function view(Planner $planner, User $user): bool + { + return $planner->id === $user->planner_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(Planner $planner): bool + { + return $this->viewAny($planner); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Planner $planner, User $model): bool + { + return $this->view($planner, $model); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Planner $planner, User $model): bool + { + return $this->view($planner, $model); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Planner $planner, User $model): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Planner $planner, User $model): bool + { + return false; + } +} diff --git a/backend/src/DishPlanner/User/Requests/CreateUserRequest.php b/backend/src/DishPlanner/User/Requests/CreateUserRequest.php new file mode 100644 index 0000000..5db139c --- /dev/null +++ b/backend/src/DishPlanner/User/Requests/CreateUserRequest.php @@ -0,0 +1,15 @@ + ['required', 'string', 'min:1', 'max:255'], + ]; + } +} diff --git a/backend/src/DishPlanner/User/Requests/UpdateUserRequest.php b/backend/src/DishPlanner/User/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..a53754a --- /dev/null +++ b/backend/src/DishPlanner/User/Requests/UpdateUserRequest.php @@ -0,0 +1,15 @@ + ['required', 'string', 'min:1', 'max:255'], + ]; + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/CreateFixedRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/CreateFixedRecurrenceAction.php new file mode 100644 index 0000000..709e4df --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/CreateFixedRecurrenceAction.php @@ -0,0 +1,33 @@ + $value, + ]); + + $userDish->recurrences()->create([ + 'recurrence_id' => $recurrence->id, + 'recurrence_type' => $recurrence::class, + ]); + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/CreateMinimumRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/CreateMinimumRecurrenceAction.php new file mode 100644 index 0000000..18e0f19 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/CreateMinimumRecurrenceAction.php @@ -0,0 +1,44 @@ +recurrences + ->filter(fn (UserDishRecurrence $recurrence) => $recurrence->recurrence_type === MinimumRecurrence::class); + + if ($existingRecurrenceForDay->isNotEmpty()) { + $first = $existingRecurrenceForDay->pop(); + $existingRecurrenceForDay->each(fn (UserDishRecurrence $recurrence) => $recurrence->delete()); + + $first->recurrence->days = $recurrenceValue; + $first->recurrence->save(); + + return; + } + + $recurrence = MinimumRecurrence::create([ + 'days' => $recurrenceValue, + ]); + + $userDish->recurrences()->create([ + 'recurrence_id' => $recurrence->id, + 'recurrence_type' => $recurrenceType, + ]); + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/CreateUserDishAction.php b/backend/src/DishPlanner/UserDish/Actions/CreateUserDishAction.php new file mode 100644 index 0000000..a21f2a6 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/CreateUserDishAction.php @@ -0,0 +1,50 @@ + $user->id, + 'dish_id' => $dish->id, + ]); + + $this->addRecurrences($userDish, $data); + + return $userDish->refresh(); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + private function addRecurrences(UserDish $userDish, array $data): void + { + $recurrenceType = Arr::get($data, 'recurrence_type'); + $recurrenceValue = Arr::get($data, 'recurrence_value'); + + if (is_null($recurrenceType) || is_null($recurrenceValue)) { + return; + } + + if ($recurrenceType === WeeklyRecurrence::class) { + (new CreateFixedRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue); + } elseif ($recurrenceType === MinimumRecurrence::class) { + (new CreateMinimumRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue); + } else { + throw new InvalidRecurrenceTypeException(); + } + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/DeleteFixedRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/DeleteFixedRecurrenceAction.php new file mode 100644 index 0000000..a9d7701 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/DeleteFixedRecurrenceAction.php @@ -0,0 +1,22 @@ +delete(); + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/DeleteMinimumRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/DeleteMinimumRecurrenceAction.php new file mode 100644 index 0000000..cfc8044 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/DeleteMinimumRecurrenceAction.php @@ -0,0 +1,28 @@ +where('recurrence_type', MinimumRecurrence::class) + ->where('recurrence_id', $recurrence->id) + ->delete(); + + $recurrence->delete(); + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/SyncRecurrencesForUserDishAction.php b/backend/src/DishPlanner/UserDish/Actions/SyncRecurrencesForUserDishAction.php new file mode 100644 index 0000000..2099712 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/SyncRecurrencesForUserDishAction.php @@ -0,0 +1,43 @@ +recurrences() + ->each(function (UserDishRecurrence $recurrence) { + $recurrence->recurrence->delete(); + $recurrence->delete(); + }); + + $recurrences->each(function (array $recurrence) use ($userDish) { + $recurrenceType = Arr::get($recurrence, 'type'); + $recurrenceValue = Arr::get($recurrence, 'value'); + + if (! $recurrenceType || ! $recurrenceValue) { + return; + } + + match ($recurrenceType) { + WeeklyRecurrence::class => (new CreateFixedRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue), + MinimumRecurrence::class => (new CreateMinimumRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue), + default => throw new InvalidRecurrenceTypeException(), + }; + }); + + return $userDish->refresh(); + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/UpdateFixedRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/UpdateFixedRecurrenceAction.php new file mode 100644 index 0000000..bd4d43b --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/UpdateFixedRecurrenceAction.php @@ -0,0 +1,32 @@ +weekday === $weekday) { + return $recurrence; + } + + $recurrence->weekday = $weekday; + $recurrence->save(); + + return $recurrence; + } +} diff --git a/backend/src/DishPlanner/UserDish/Actions/UpdateMinimumRecurrenceAction.php b/backend/src/DishPlanner/UserDish/Actions/UpdateMinimumRecurrenceAction.php new file mode 100644 index 0000000..0142dad --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Actions/UpdateMinimumRecurrenceAction.php @@ -0,0 +1,32 @@ +days === $days) { + return $recurrence; + } + + $recurrence->days = $days; + $recurrence->save(); + + return $recurrence; + } +} diff --git a/backend/src/DishPlanner/UserDish/Controllers/ListUserDishesController.php b/backend/src/DishPlanner/UserDish/Controllers/ListUserDishesController.php new file mode 100644 index 0000000..c515152 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Controllers/ListUserDishesController.php @@ -0,0 +1,26 @@ +user(); + + /** @var UserDishRepository $userDishRepository */ + $userDishRepository = resolve(UserDishRepository::class); + $userDishes = $userDishRepository->getAllForPlanner($planner); + + return $this->success([ + 'user_dishes' => UserDishResource::collection($userDishes)->collection->toArray() + ]); + } +} diff --git a/backend/src/DishPlanner/UserDish/Controllers/UserDishController.php b/backend/src/DishPlanner/UserDish/Controllers/UserDishController.php new file mode 100644 index 0000000..23eea18 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Controllers/UserDishController.php @@ -0,0 +1,64 @@ +success([ + 'user' => new UserWithUserDishesResource($user), + ]); + } + + public function show(User $user, Dish $dish): JsonResponse + { + Gate::authorize('view', $user); + Gate::authorize('view', $dish); + + $userDish = UserDish::query() + ->where('user_id', $user->id) + ->where('dish_id', $dish->id) + ->firstOrFail(); + + Gate::authorize('view', $userDish); + + return $this->success([ + 'user_dish' => new UserDishResource($userDish), + ]); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + public function store(CreateUserDishRequest $request, User $user, Dish $dish): JsonResponse + { + $userDish = (new CreateUserDishAction())->execute($dish, $user, $request->validated()); + + return $this->success([ + 'user_dish' => new UserDishResource($userDish), + ]); + } + + public function destroy(User $user, Dish $dish): JsonResponse + { + (new DeleteUserDishAction())->execute($user, $dish); + + return $this->success(null); + } +} diff --git a/backend/src/DishPlanner/UserDish/Controllers/UserDishRecurrenceController.php b/backend/src/DishPlanner/UserDish/Controllers/UserDishRecurrenceController.php new file mode 100644 index 0000000..425fba1 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Controllers/UserDishRecurrenceController.php @@ -0,0 +1,98 @@ +error('UNKNOWN_USER_DISH', 404); + } + + $recurrences = collect($request->validated()); + + (new SyncRecurrencesForUserDishAction())->execute($userDish, $recurrences); + + return $this->success([ + 'user_dish' => new UserDishResource($userDish->refresh()), + ]); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + public function update(UpdateUserDishFixedRecurrenceRequest $request, UserDish $userDish, string $recurrenceType, int $recurrenceId): JsonResponse + { + $recurrenceClass = $this->getRecurrenceClass($recurrenceType); + $recurrence = $recurrenceClass::findOrFail($recurrenceId); + + if ($recurrence instanceof WeeklyRecurrence) { + (new UpdateFixedRecurrenceAction())->execute($recurrence, $request->validated()); + } elseif ($recurrenceClass === MinimumRecurrence::class) { + (new UpdateMinimumRecurrenceAction())->execute($recurrence, $request->validated()); + } else { + return $this->error('invalid recurrence type'); + } + + return $this->success([ + 'user_dish' => new UserDishResource($userDish->refresh()), + ]); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + public function destroy(UserDish $userDish, string $recurrenceType, int $recurrenceId): JsonResponse + { + $recurrenceClass = $this->getRecurrenceClass($recurrenceType); + $recurrence = $recurrenceClass::findOrFail($recurrenceId); + + if ($recurrence instanceof WeeklyRecurrence) { + (new DeleteFixedRecurrenceAction())->execute($recurrence); + } elseif ($recurrenceClass === MinimumRecurrence::class) { + (new DeleteMinimumRecurrenceAction())->execute($recurrence); + } else { + return $this->error('invalid recurrence type'); + } + + return $this->success([ + 'user_dish' => new UserDishResource($userDish->refresh()), + ]); + } + + /** + * @throws InvalidRecurrenceTypeException + */ + private function getRecurrenceClass(string $recurrenceType): string + { + return match ($recurrenceType) { + 'fixed' => WeeklyRecurrence::class, + 'minimum' => MinimumRecurrence::class, + default => throw new InvalidRecurrenceTypeException("Invalid recurrence type: $recurrenceType") + }; + } +} diff --git a/backend/src/DishPlanner/UserDish/Exceptions/InvalidRecurrenceTypeException.php b/backend/src/DishPlanner/UserDish/Exceptions/InvalidRecurrenceTypeException.php new file mode 100644 index 0000000..e528e3f --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Exceptions/InvalidRecurrenceTypeException.php @@ -0,0 +1,10 @@ +id === $userDish->dish?->planner_id && $planner->id === $userDish->user?->planner_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(Planner $planner): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(Planner $planner, UserDish $plannerDish): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Planner $planner, UserDish $plannerDish): bool + { + return $this->view($planner, $plannerDish); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Planner $planner, UserDish $plannerDish): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Planner $planner, UserDish $plannerDish): bool + { + return false; + } + + /** + * Assign the userDish to a schedule + */ + public function assign(Planner $planner, UserDish $userDish): bool + { + return $planner->id === $userDish->dish?->planner_id && $planner->id === $userDish->user?->planner_id; + } +} diff --git a/backend/src/DishPlanner/UserDish/Repositories/UserDishRepository.php b/backend/src/DishPlanner/UserDish/Repositories/UserDishRepository.php new file mode 100644 index 0000000..789c335 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Repositories/UserDishRepository.php @@ -0,0 +1,104 @@ +findCandidatesForDate($user, $date)->random(); + } + + public function findCandidatesForDate(User $user, Carbon $date): SupportCollection + { + /** @var UserDishRepository $userDishRepository */ + $userDishRepository = resolve(UserDishRepository::class); + $userDish = $userDishRepository->findForUserByFixedRecurrenceOnDay($user, $date->dayOfWeek); + + if ($userDish) { + return collect([$userDish]); + } + + $interferingUserDishes = $userDishRepository->findInterferingUserDishes($user, $date); + + return $user->userDishes + ->reject(fn (UserDish $userDish) => $interferingUserDishes + ->filter(fn (UserDish $ud) => $ud->id === $userDish->id) + ->isNotEmpty() + ); + } + + public function findForUserByFixedRecurrenceOnDay(User $user, int $dayOfWeek): ?UserDish + { + return UserDish::query() + ->where('user_id', $user->id) + ->whereHas('recurrences', fn ($query) => $query + ->where('recurrence_type', WeeklyRecurrence::class) + ->whereHasMorph( + 'recurrence', + [WeeklyRecurrence::class], + fn ($query) => $query->where('weekday', $dayOfWeek) + ) + ) + ->first(); + } + + // Interfering: their minimum overlaps the date + public function findInterferingUserDishes(User $user, Carbon $date): Collection + { + // get maximum interval number + $maxInterval = MinimumRecurrence::query()->max('days'); + + // get whole schedule for now - maxInterval <> now + maxInterval + return new Collection( + Schedule::query() + ->whereBetween('date', [$date->copy()->subDays($maxInterval), $date->copy()->addDays($maxInterval)]) + ->get() + ->flatMap(fn (Schedule $schedule) => $schedule->scheduledUserDishes) + ->filter(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->user_id === $user->id) + ->filter(fn(ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->recurrences->contains('recurrence_type', MinimumRecurrence::class)) + ->filter(function (ScheduledUserDish $scheduledUserDish) use ($date) { + $minimum = $scheduledUserDish->userDish + ->recurrences + ->firstWhere('recurrence_type', MinimumRecurrence::class) + ->recurrence + ->days; + $dateRange = CarbonPeriod::create($scheduledUserDish->schedule->date->subDays($minimum), $scheduledUserDish->schedule->date->addDays($minimum)); + + return $dateRange->contains($date); + }) + ->map(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish) + ); + } + + public static function getForUserAndDish(User $user, Dish $dish): ?UserDish + { + return UserDish::query() + ->where('user_id', $user->id) + ->where('dish_id', $dish->id) + ->first(); + } + + public function getAllForPlanner(Planner $planner): Collection + { + return UserDish::query() + ->with(['user', 'dish']) + ->whereHas('user', fn ($query) => $query->where('planner_id', $planner->id)) + ->whereHas('dish', fn ($query) => $query->where('planner_id', $planner->id)) + ->get(); + } +} diff --git a/backend/src/DishPlanner/UserDish/Requests/CreateUserDishRequest.php b/backend/src/DishPlanner/UserDish/Requests/CreateUserDishRequest.php new file mode 100755 index 0000000..e6c5bd7 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Requests/CreateUserDishRequest.php @@ -0,0 +1,22 @@ + ['sometimes', Rule::in([ + WeeklyRecurrence::class, + MinimumRecurrence::class, + ])], + 'recurrence_value' => ['required_with:recurrence_type', 'integer'], + ]; + } +} diff --git a/backend/src/DishPlanner/UserDish/Requests/StoreUserDishRecurrenceRequest.php b/backend/src/DishPlanner/UserDish/Requests/StoreUserDishRecurrenceRequest.php new file mode 100644 index 0000000..2c50204 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Requests/StoreUserDishRecurrenceRequest.php @@ -0,0 +1,27 @@ + [ + 'sometimes', + 'string', + Rule::in([ + MinimumRecurrence::class, + WeeklyRecurrence::class, + ]), + 'required_with:*.recurrence_value' + ], + '*.value' => ['sometimes', 'integer', 'required_with:*.recurrence_type'], + ]; + } +} diff --git a/backend/src/DishPlanner/UserDish/Requests/UpdateUserDishFixedRecurrenceRequest.php b/backend/src/DishPlanner/UserDish/Requests/UpdateUserDishFixedRecurrenceRequest.php new file mode 100644 index 0000000..49b23f9 --- /dev/null +++ b/backend/src/DishPlanner/UserDish/Requests/UpdateUserDishFixedRecurrenceRequest.php @@ -0,0 +1,35 @@ + [ + 'required', + 'string', + 'in:' . implode(',', [ + MinimumRecurrence::class, + WeeklyRecurrence::class, + ]), + ], + 'recurrence_data' => 'required|array', + 'recurrence_data.days' => 'required_if:recurrence_type,' . MinimumRecurrence::class . '|integer|min:1', + 'recurrence_data.weekday' => 'required_if:recurrence_type,' . WeeklyRecurrence::class . '|integer|between:0,6', + ]; + } + + public function messages(): array + { + return [ + 'recurrence_data.days.required_if' => 'The days field is required for minimum recurrence.', + 'recurrence_data.weekday.required_if' => 'The weekday field is required for weekly recurrence.', + ]; + } +} diff --git a/backend/storage/app/.gitignore b/backend/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/backend/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/backend/storage/app/private/.gitignore b/backend/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/app/public/.gitignore b/backend/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/framework/.gitignore b/backend/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/backend/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/backend/storage/framework/cache/.gitignore b/backend/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/backend/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/backend/storage/framework/cache/data/.gitignore b/backend/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/framework/sessions/.gitignore b/backend/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/framework/testing/.gitignore b/backend/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/framework/views/.gitignore b/backend/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/storage/logs/.gitignore b/backend/storage/logs/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/tailwind.config.js b/backend/tailwind.config.js new file mode 100644 index 0000000..ce0c57f --- /dev/null +++ b/backend/tailwind.config.js @@ -0,0 +1,20 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', + './storage/framework/views/*.php', + './resources/**/*.blade.php', + './resources/**/*.js', + './resources/**/*.vue', + ], + theme: { + extend: { + fontFamily: { + sans: ['Figtree', ...defaultTheme.fontFamily.sans], + }, + }, + }, + plugins: [], +}; diff --git a/backend/tests/Feature/Dish/AddUsersToDishTest.php b/backend/tests/Feature/Dish/AddUsersToDishTest.php new file mode 100755 index 0000000..2c65a7a --- /dev/null +++ b/backend/tests/Feature/Dish/AddUsersToDishTest.php @@ -0,0 +1,88 @@ +planner; + $users = User::factory()->planner($planner)->count($userCount)->create(); + $dish = Dish::factory()->planner($planner)->create(); + + $this->assertDatabaseEmpty(UserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.add', [ + 'dish' => $dish, + ]), [ + 'users' => $users->map->id->toArray(), + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->has('id') + ->where('planner_id', $planner->id) + ->where('name', $dish->name) + ->has('users', $userCount) + ->where('users', $users + ->map(fn (User $user) => [ + 'id' => $user->id, + 'name' => $user->name, + ])->toArray() + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(UserDish::class, $userCount); + + $dish->refresh(); + $this->assertEquals($userCount, $dish->users->count()); + } + + public function test_it_does_not_sync_users_to_a_user_dish_from_another_planner(): void + { + $userCount = 4; + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $users = User::factory()->planner($otherPlanner)->count($userCount)->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + + $this->assertDatabaseEmpty(UserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.add', [ + 'dish' => $dish, + ]), [ + 'users' => $users->map->id->toArray(), + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + + $this->assertDatabaseCount(UserDish::class, 0); + $this->assertEquals(0, $dish->refresh()->users->count()); + } +} diff --git a/backend/tests/Feature/Dish/CreateDishTest.php b/backend/tests/Feature/Dish/CreateDishTest.php new file mode 100755 index 0000000..1f551e1 --- /dev/null +++ b/backend/tests/Feature/Dish/CreateDishTest.php @@ -0,0 +1,89 @@ +planner; + + $this->assertDatabaseEmpty(Dish::class); + $this->assertDatabaseEmpty('user_dishes'); + + $this + ->actingAs($planner) + ->post(route('api.dishes.store'), [ + 'name' => 'Pizza', + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->has('id') + ->where('planner_id', $planner->id) + ->where('name', 'Pizza') + ->has('users') + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(Dish::class, 1); + + $dish = Dish::all()->first(); + $this->assertEquals($planner->id, $dish->planner_id); + } + + #[DataProvider('invalidNameValues')] + public function test_it_throws_exception_for_invalid_name_values(?string $name, string $expectedError): void + { + $planner = $this->planner; + + $this + ->actingAs($planner) + ->post(route('api.dishes.store'), $name ? ['name' => $name] : []) +// ->assertStatus(422) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', [$expectedError]) + ); + + } + + public static function invalidNameValues(): array + { + return [ + 'empty name' => [ + 'name' => '', + 'expectedError' => 'The name field is required.', + ], + 'null name' => [ + 'name' => null, + 'expectedError' => 'The name field is required.', + ], + 'too long name' => [ + 'name' => Str::random(130), + 'expectedError' => 'The name field must not be greater than 128 characters.', + ], + 'too short name' => [ + 'name' => 'a', + 'expectedError' => 'The name field must be at least 3 characters.', + ], + ]; + } +} diff --git a/backend/tests/Feature/Dish/DeleteDishTest.php b/backend/tests/Feature/Dish/DeleteDishTest.php new file mode 100644 index 0000000..efbcd54 --- /dev/null +++ b/backend/tests/Feature/Dish/DeleteDishTest.php @@ -0,0 +1,82 @@ +planner; + $dish = Dish::factory()->planner($planner)->create(); + + $this->assertDatabaseCount(Dish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.dishes.destroy', $dish)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseEmpty(Dish::class); + } + + public function test_it_deletes_user_dishes_when_deleting_a_dish(): void + { + $planner = $this->planner; + $dish = Dish::factory()->planner($planner)->create(); + $user = User::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this->assertDatabaseCount(UserDish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.dishes.destroy', $dish)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseEmpty(UserDish::class); + } + + + public function test_planner_cannot_delete_dish_from_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + + $this->assertDatabaseCount(Dish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.dishes.destroy', $dish)) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + + $this->assertDatabaseCount(Dish::class, 1); + } +} diff --git a/backend/tests/Feature/Dish/ListDishesTest.php b/backend/tests/Feature/Dish/ListDishesTest.php new file mode 100755 index 0000000..0f2bff9 --- /dev/null +++ b/backend/tests/Feature/Dish/ListDishesTest.php @@ -0,0 +1,61 @@ +planner; + $dishes = Dish::factory()->planner($planner)->count(rand(2, 10))->create(); + + $this + ->actingAs($planner) + ->get(route('api.dishes.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->where('dishes', $dishes + ->map(fn (Dish $dish) => [ + 'id' => $dish->id, + 'planner_id' => $planner->id, + 'name' => $dish->name, + 'users' => $dish->users->pluck('id')->toArray(), + ]) + ->toArray() + ) + ) + ->where('errors', null) + ); + } + + public function test_user_cannot_see_list_of_other_users_dishes(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + Dish::factory()->planner($otherPlanner)->count(rand(2, 10))->create(); + + $this + ->actingAs($planner) + ->get(route('api.dishes.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->where('dishes', []) + ) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/Dish/RemoveUsersFromDishTest.php b/backend/tests/Feature/Dish/RemoveUsersFromDishTest.php new file mode 100755 index 0000000..d300140 --- /dev/null +++ b/backend/tests/Feature/Dish/RemoveUsersFromDishTest.php @@ -0,0 +1,94 @@ +planner; + $users = User::factory()->planner($planner)->count($userCount)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $dish->users()->attach($users); + + $removedUser = $users->random(); + $remainingUsers = $users->reject(fn (User $user) => $user->id === $removedUser->id)->values(); + + $this->assertDatabaseCount(UserDish::class, $userCount); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.remove', [ + 'dish' => $dish, + ]), [ + 'users' => [$removedUser->id], + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->has('id') + ->where('planner_id', $planner->id) + ->where('name', $dish->name) + ->has('users', $remainingUsers->count()) + ->where('users', $remainingUsers + ->map(fn (User $user) => [ + 'id' => $user->id, + 'name' => $user->name, + ])->toArray() + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(UserDish::class, $userCount - 1); + + $dish->refresh(); + $this->assertEquals($userCount - 1, $dish->users->count()); + } + + public function test_it_does_not_sync_users_to_a_user_dish_of_another_planner(): void + { + $userCount = 4; + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $users = User::factory()->planner($otherPlanner)->count($userCount)->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + $dish->users()->attach($users); + + $removedUser = $users->random(); + + $this->assertDatabaseCount(UserDish::class, $userCount); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.remove', [ + 'dish' => $dish, + ]), [ + 'users' => [$removedUser->id], + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + + $this->assertDatabaseCount(UserDish::class, $userCount); + } +} diff --git a/backend/tests/Feature/Dish/ShowDishTest.php b/backend/tests/Feature/Dish/ShowDishTest.php new file mode 100755 index 0000000..8990f35 --- /dev/null +++ b/backend/tests/Feature/Dish/ShowDishTest.php @@ -0,0 +1,51 @@ +planner; + $dish = Dish::factory()->planner($planner)->create(); + + $this + ->actingAs($planner) + ->get(route('api.dishes.show', $dish)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->where('id', $dish->id) + ->where('planner_id', $planner->id) + ->where('name', $dish->name) + ->has('users') + ) + ) + ->where('errors', null) + ); + } + + public function test_user_cannot_see_dish_from_other_user(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + + $this + ->actingAs($planner) + ->get(route('api.dishes.show', $dish)) + ->assertStatus(404); + } +} diff --git a/backend/tests/Feature/Dish/SyncUsersForDishTest.php b/backend/tests/Feature/Dish/SyncUsersForDishTest.php new file mode 100755 index 0000000..69b0224 --- /dev/null +++ b/backend/tests/Feature/Dish/SyncUsersForDishTest.php @@ -0,0 +1,88 @@ +planner; + $users = User::factory()->planner($planner)->count($userCount)->create(); + $dish = Dish::factory()->planner($planner)->create(); + + $this->assertDatabaseEmpty(UserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.sync', [ + 'dish' => $dish, + ]), [ + 'users' => $users->map->id->toArray(), + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->has('id') + ->where('planner_id', $planner->id) + ->where('name', $dish->name) + ->has('users', $userCount) + ->where('users', $users + ->map(fn (User $user) => [ + 'id' => $user->id, + 'name' => $user->name, + ])->toArray() + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(UserDish::class, $userCount); + + $dish->refresh(); + $this->assertEquals($userCount, $dish->users->count()); + } + + public function test_it_does_not_sync_users_to_a_user_dish_of_other_planner(): void + { + $userCount = 4; + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $users = User::factory()->planner($otherPlanner)->count($userCount)->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + + $this->assertDatabaseEmpty(UserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.dishes.users.sync', [ + 'dish' => $dish, + ]), [ + 'users' => $users->map->id->toArray(), + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + + $this->assertDatabaseEmpty(UserDish::class); + $this->assertEmpty($dish->refresh()->users->count()); + } +} diff --git a/backend/tests/Feature/Dish/UpdateDishTest.php b/backend/tests/Feature/Dish/UpdateDishTest.php new file mode 100755 index 0000000..907d03e --- /dev/null +++ b/backend/tests/Feature/Dish/UpdateDishTest.php @@ -0,0 +1,80 @@ +planner; + $nameOriginal = 'Pizza'; + $nameUpdated = 'Lasagne'; + + $dish = Dish::factory() + ->planner($planner) + ->create([ + 'name' => $nameOriginal, + ]); + + $this + ->actingAs($planner) + ->put(route('api.dishes.update', $dish->id), [ + 'name' => $nameUpdated, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('dish', fn ($json) => $json + ->where('id', $dish->id) + ->where('planner_id', $planner->id) + ->where('name', $nameUpdated) + ->has('users') + ) + ) + ->where('errors', null) + ); + + $dish->refresh(); + $this->assertEquals($nameUpdated, $dish->name); + } + + public function test_user_cannot_update_other_users_dish(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $nameOriginal = 'Pizza'; + $nameUpdated = 'Lasagne'; + + $dish = Dish::factory() + ->planner($otherPlanner) + ->create([ + 'name' => $nameOriginal, + ]); + + $this + ->actingAs($planner) + ->put(route('api.dishes.update', $dish->id), [ + 'name' => $nameUpdated, + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + + $dish->refresh(); + $this->assertEquals($nameOriginal, $dish->name); + } +} diff --git a/backend/tests/Feature/PlannerLoginTest.php b/backend/tests/Feature/PlannerLoginTest.php new file mode 100644 index 0000000..eccf3a5 --- /dev/null +++ b/backend/tests/Feature/PlannerLoginTest.php @@ -0,0 +1,98 @@ +create([ + 'email' => 'planner@example.com', + 'password' => Hash::make('secret123'), + ]); + + $response = $this + ->actingAs($planner) + ->post(route('api.auth.login'), [ + 'email' => 'planner@example.com', + 'password' => 'secret123', + ]); + + $response->assertOk(); + $this->assertAuthenticatedAs($planner); + } + + public function test_login_fails_with_invalid_credentials(): void + { + Planner::factory()->create([ + 'email' => 'planner@example.com', + 'password' => Hash::make('secret123'), + ]); + + $response = $this->postJson(route('api.auth.login'), [ + 'email' => 'planner@example.com', + 'password' => 'wrongpassword', + ]); + + $response->assertUnauthorized(); + } + + public function test_a_logged_in_planner_can_log_out(): void + { + $planner = Planner::factory()->create([ + 'password' => Hash::make('secret123'), + ]); + + $this->post(route('api.auth.login'), [ + 'email' => $planner->email, + 'password' => 'secret123', + ]); + + $response = $this->post(route('api.auth.logout')); + + $response->assertOk(); + $this->assertGuest(); // nobody should be logged in after logout + } + + public function test_planner_can_register(): void + { + $schedulesCount = Schedule::all()->count(); + + $response = $this->post(route('api.auth.register'), [ + 'name' => 'High Functioning Planner', + 'email' => 'planner@example.com', + 'password' => 'secret123', + 'password_confirmation' => 'secret123', + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('planners', [ + 'email' => 'planner@example.com', + ]); + + $this->assertGreaterThan($schedulesCount, Schedule::all()->count()); + } + + public function test_it_returns_the_authenticated_planner(): void + { + $planner = Planner::factory()->create(); + + $this + ->actingAs($planner) + ->get(route('api.auth.me')) + ->assertOk() + ->assertJsonFragment([ + 'email' => $planner->email, + 'name' => $planner->name, + ]); + } +} diff --git a/backend/tests/Feature/Schedule/GenerateScheduleTest.php b/backend/tests/Feature/Schedule/GenerateScheduleTest.php new file mode 100644 index 0000000..7ed1888 --- /dev/null +++ b/backend/tests/Feature/Schedule/GenerateScheduleTest.php @@ -0,0 +1,257 @@ +planner; + $users = User::factory()->planner($planner)->count(2)->create(); // Create users + $dishes = Dish::factory()->planner($planner)->count(200)->create(); // Create dishes + + // Attach dishes to users, ensuring `UserDish` records exist for every user + $users->each(function (User $user) use ($dishes) { + $dishes->random(50)->each(function (Dish $dish) use ($user) { + UserDish::factory()->create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + }); + }); + + $this->assertDatabaseEmpty(Schedule::class); + + $this + ->actingAs($planner) + ->post(route('api.schedule.generate')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseCount(Schedule::class, 14); + } + + public function test_fresh_schedule_adheres_to_fixed_recurrences(): void + { + $planner = $this->planner; + $targetDate = now()->addDay(); + $users = User::factory()->planner($planner)->count(2)->create(); // Create users + $dishes = Dish::factory()->planner($planner)->count(200)->create(); // Create dishes + + // Attach dishes to users, ensuring `UserDish` records exist for every user + $users->each(function (User $user) use ($dishes) { + $dishes->random(50)->each(function (Dish $dish) use ($user) { + UserDish::factory()->create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + }); + }); + + $targetUserDish = UserDish::query() + ->whereNotNull('dish_id') + ->whereNotNull('user_id') + ->get() + ->random(); + + $weeklyRecurrence = WeeklyRecurrence::factory()->create([ + 'weekday' => $targetDate->dayOfWeek(), + ]); + + $fixedRecurrence = UserDishRecurrence::factory()->create([ + 'user_dish_id' => $targetUserDish->id, + 'recurrence_id' => $weeklyRecurrence->id, + 'recurrence_type' => $weeklyRecurrence::class, + ]); + + $this->assertDatabaseEmpty(Schedule::class); + + $this + ->actingAs($planner) + ->post(route('api.schedule.generate')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseCount(Schedule::class, 14); + $targetDateSchedule = Schedule::query()->where('date', $targetDate->format('Y-m-d'))->first(); + + $this->assertNotNull($targetDateSchedule); + + $targetScheduledUserDishes = $targetDateSchedule + ->scheduledUserDishes + ->pluck('user_dish_id') + ->toArray(); + + $this->assertContains($targetUserDish->id, $targetScheduledUserDishes); + } + public function test_schedule_can_be_overwritten(): void + { + $planner = $this->planner; + $users = User::factory()->planner($planner)->count(2)->create(); // Create users + $dishes = Dish::factory()->planner($planner)->count(200)->create(); // Create dishes + + // Attach dishes to users, ensuring `UserDish` records exist for every user + $users->each(function (User $user) use ($dishes) { + $dishes->random(50)->each(function (Dish $dish) use ($user) { + UserDish::factory()->create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + }); + }); + + // Assert that every user has `UserDish` records + $users->each(fn (User $user) => + $this->assertNotEmpty($user->refresh()->userDishes) + ); + + $scheduleDay = Schedule::factory() + ->planner($planner) + ->create([ + 'date' => Carbon::now()->addDay(), + ]); + + $users->each(fn (User $user) => ScheduledUserDish::factory() + ->create([ + 'user_dish_id' => $user->userDishes->random()->id, + 'schedule_id' => $scheduleDay->id, + 'user_id' => $user->id, + ]) + ); + + $scheduleDay->refresh(); + $originalUserDishes = $scheduleDay->scheduledUserDishes->map(fn (ScheduledUserDish $scheduledUserDish) => [ + 'user_dish_id' => $scheduledUserDish->user_dish_id, + ]); + + $this->assertDatabaseCount(Schedule::class, 1); + $this->assertDatabaseCount(ScheduledUserDish::class, 2); + + $this + ->actingAs($planner) + ->post(route('api.schedule.generate'), [ + 'overwrite' => true, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseCount(Schedule::class, 14); + + $freshScheduleDay = Schedule::query()->where('date', $scheduleDay->date)->first(); + $this->assertNotEquals( + $originalUserDishes, + $freshScheduleDay->scheduledUserDishes->map(fn (ScheduledUserDish $scheduledUserDish) => [ + 'user_dish_id' => $scheduledUserDish->user_dish_id, + ]) + ); + } + + public function test_fixed_recurrence_takes_precedence_during_overwrite(): void + { + $targetDate = now()->addDay(); + $planner = $this->planner; + $users = User::factory()->planner($planner)->count(2)->create(); // Create users + $dishes = Dish::factory()->planner($planner)->count(200)->create(); // Create dishes + + // Attach dishes to users, ensuring `UserDish` records exist for every user + $users->each(function (User $user) use ($dishes) { + $dishes->random(50)->each(function (Dish $dish) use ($user) { + UserDish::factory()->create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + }); + }); + + $scheduleDay = Schedule::factory() + ->planner($planner) + ->date($targetDate) + ->create(); + + $fixedUser = $users->pop(); + $targetUserDish = $fixedUser->userDishes->random(); + + $weeklyRecurrence = WeeklyRecurrence::factory()->create([ + 'weekday' => $targetDate->dayOfWeek, + ]); + + UserDishRecurrence::factory()->create([ + 'user_dish_id' => $targetUserDish->id, + 'recurrence_id' => $weeklyRecurrence->id, + 'recurrence_type' => $weeklyRecurrence::class, + ]); + + $users + ->map(fn (User $user) => ScheduledUserDish::factory() + ->create([ + 'schedule_id' => $scheduleDay->id, + 'user_dish_id' => $user->userDishes->random()->id, + 'user_id' => $user->id, + ])); + + ScheduledUserDish::factory() + ->schedule($scheduleDay) + ->userDish($targetUserDish) + ->create(); + + $scheduleDay->refresh(); + + // assert all days have been filled + $this->assertDatabaseCount(Schedule::class, 1); + $this->assertDatabaseCount(ScheduledUserDish::class, 2); + + + $this + ->actingAs($planner) + ->post(route('api.schedule.generate'), [ + 'overwrite' => true, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseCount(Schedule::class, 14); + + $targetDateSchedule = Schedule::query()->where('date', $targetDate->format('Y-m-d'))->first(); + $this->assertNotNull($targetDateSchedule); + + $targetScheduledUserDishes = $targetDateSchedule + ->scheduledUserDishes + ->filter(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->user->id === $fixedUser->id); + + $this->assertCount(1, $targetScheduledUserDishes); + $this->assertContains($targetUserDish->id, $targetScheduledUserDishes->pluck('user_dish_id')->toArray()); + } +} diff --git a/backend/tests/Feature/Schedule/ListScheduleTest.php b/backend/tests/Feature/Schedule/ListScheduleTest.php new file mode 100644 index 0000000..be3dc3d --- /dev/null +++ b/backend/tests/Feature/Schedule/ListScheduleTest.php @@ -0,0 +1,97 @@ +planner; + $expectedDateRangeStart = '2024-01-03'; + $expectedDateRangeEnd = '2024-01-05'; + + $users = User::factory()->planner($planner)->count(10)->create(); + $dishes = Dish::factory()->planner($planner)->count(10)->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->attach($users)); + + $period = CarbonPeriod::create($expectedDateRangeStart, $expectedDateRangeEnd); + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + $users->each(function (User $user) use ($schedule) { + $randomUserDish = $user->userDishes->random(); + return $schedule->scheduledUserDishes()->create([ + 'user_dish_id' => $randomUserDish->id, + 'user_id' => $randomUserDish->user->id, + ]); + }); + } + + $this + ->actingAs($planner) + ->get(url()->query(route('api.schedule.index'), [ + 'start' => $expectedDateRangeStart, + 'end' => $expectedDateRangeEnd, + ])) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('schedule', Schedule::query() + ->whereBetween('date', [$expectedDateRangeStart, $expectedDateRangeEnd]) + ->count() + ) + ) + ->where('errors', null) + ); + } + + public function test_it_does_not_show_dishes_of_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $expectedDateRangeStart = '2024-01-03'; + $expectedDateRangeEnd = '2024-01-05'; + + $users = User::factory()->planner($otherPlanner)->count(10)->create(); + $dishes = Dish::factory()->planner($otherPlanner)->count(10)->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->attach($users)); + + $period = CarbonPeriod::create($expectedDateRangeStart, $expectedDateRangeEnd); + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($otherPlanner)->date($date)->create(); + $users->each(function (User $user) use ($schedule) { + $randomUserDish = $user->userDishes->random(); + return $schedule->scheduledUserDishes()->create([ + 'user_dish_id' => $randomUserDish->id, + 'user_id' => $randomUserDish->user->id, + ]); + }); + } + + $this + ->actingAs($planner) + ->get(url()->query(route('api.schedule.index'), [ + 'start' => $expectedDateRangeStart, + 'end' => $expectedDateRangeEnd, + ])) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload.schedule', []) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/Schedule/ReadScheduleTest.php b/backend/tests/Feature/Schedule/ReadScheduleTest.php new file mode 100644 index 0000000..3c473b6 --- /dev/null +++ b/backend/tests/Feature/Schedule/ReadScheduleTest.php @@ -0,0 +1,111 @@ +planner; + $userOne = User::factory()->planner($planner)->create(['name' => 'Melissa']); + $userTwo = User::factory()->planner($planner)->create(['name' => 'Jochen']); + + $users = collect([$userOne, $userTwo]); + $dishes = Dish::factory()->planner($planner)->count(rand(15, 20))->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->sync($users->pluck('id')->toArray())); + + $dateRangeStart = '2024-01-01'; + $dateRangeEnd = '2024-01-07'; + + $period = CarbonPeriod::create($dateRangeStart, $dateRangeEnd); + + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + + $users->each(fn (User $user) => ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($user->userDishes->random()) + ->create() + ); + } + + $randomSchedule = $planner->schedules->random(); + + $this + ->actingAs($planner) + ->get(route('api.schedule.show', $randomSchedule->date->format('Y-m-d'))) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('schedule', fn (AssertableJson $json) => $json + ->where('id', $randomSchedule->id) + ->where('is_skipped', false) + ->where('date', $randomSchedule->date->format('Y-m-d')) + ->has('scheduled_user_dishes', 2) + ) + ) + ->where('errors', null) + ); + } + + public function test_single_day_cannot_be_read_by_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $userOne = User::factory()->planner($otherPlanner)->create(['name' => 'Melissa']); + $userTwo = User::factory()->planner($otherPlanner)->create(['name' => 'Jochen']); + + $users = collect([$userOne, $userTwo]); + $dishes = Dish::factory()->planner($otherPlanner)->count(rand(15, 20))->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->sync($users->pluck('id')->toArray())); + + $dateRangeStart = '2024-01-01'; + $dateRangeEnd = '2024-01-07'; + + $period = CarbonPeriod::create($dateRangeStart, $dateRangeEnd); + + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($otherPlanner)->date($date)->create(); + + $users->each(fn (User $user) => ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($user->userDishes->random()) + ->create() + ); + } + + $this->assertEmpty($planner->schedules); + + $randomDateFromPeriod = collect($period)->random()->format('Y-m-d'); + + $this + ->actingAs($planner) + ->get(route('api.schedule.show', $randomDateFromPeriod)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload.schedule', fn (AssertableJson $json) => $json + ->has('id') + ->where('date', $randomDateFromPeriod) + ->where('is_skipped', null) + ->where('scheduled_user_dishes', []) + ) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/Schedule/ScheduleUserDishTest.php b/backend/tests/Feature/Schedule/ScheduleUserDishTest.php new file mode 100644 index 0000000..8c3fd83 --- /dev/null +++ b/backend/tests/Feature/Schedule/ScheduleUserDishTest.php @@ -0,0 +1,100 @@ +create(); + + $date = Carbon::tomorrow()->startOfDay(); + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $userDish = UserDish::factory()->user($user)->dish($dish)->create(); + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + + $this + ->actingAs($planner) + ->post( + uri: route('api.schedule.user-dish.update', $schedule->date->format('Y-m-d')), + data: [ + 'user_dish_id' => $userDish->id, + 'user_id' => $user->id, + ] + ) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn (AssertableJson $json) => $json + ->where('schedule.id', $schedule->id) + ->where('schedule.scheduled_user_dishes.0.user_dish.id', $userDish->id) + ->where('schedule.scheduled_user_dishes.0.user_dish.dish.id', $dish->id) + ->etc() + ) + ->where('errors', null) + ); + } + + public function test_skipping_for_single_user(): void + { + $planner = Planner::factory()->create(); + + $this->assertDatabaseEmpty(ScheduledUserDish::class); + + $date = Carbon::tomorrow()->startOfDay(); + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $userDish = UserDish::factory()->user($user)->dish($dish)->create(); + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($userDish) + ->user($user) + ->create(); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + + $this + ->actingAs($planner) + ->post( + uri: route('api.schedule.user-dish.update', $schedule->date->format('Y-m-d')), + data: [ + 'user_dish_id' => null, + 'user_id' => $user->id, + 'skipped' => true, + ] + ) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn (AssertableJson $json) => $json + ->where('schedule.id', $schedule->id) + ->has('schedule.scheduled_user_dishes', 1) + ->has('schedule.scheduled_user_dishes.0', fn (AssertableJson $json) => $json + ->whereNull('user_dish') + ->where('user.id', $user->id) + ->where('skipped', true) + ->etc() + ) + ->etc() + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + } +} diff --git a/backend/tests/Feature/Schedule/UpdateScheduleTest.php b/backend/tests/Feature/Schedule/UpdateScheduleTest.php new file mode 100644 index 0000000..ab10c86 --- /dev/null +++ b/backend/tests/Feature/Schedule/UpdateScheduleTest.php @@ -0,0 +1,66 @@ +planner; + $userOne = User::factory()->planner($planner)->create(); + $userTwo = User::factory()->planner($planner)->create(); + $users = collect([$userOne, $userTwo]); + $schedule = Schedule::factory()->planner($planner)->create(); + $dishes = Dish::factory()->planner($planner)->count(20)->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->attach($users)); + ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($dishes->random()->userDishes->random()) + ->create([]); + ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($dishes->random()->userDishes->random()) + ->create([]); + + $schedule->refresh(); + + $this->assertFalse($schedule->is_skipped); + $this->assertCount(2, $schedule->scheduledUserDishes); + $schedule->scheduledUserDishes->each(fn ($scheduledUserDish) => $this->assertNotNull($scheduledUserDish->user_dish_id)); + + $this + ->actingAs($planner) + ->put(route('api.schedule.update', $schedule->date->format('Y-m-d')), [ + 'is_skipped' => true, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('schedule', fn (AssertableJson $json) => $json + ->where('id', $schedule->id) + ->where('date', $schedule->date->format('Y-m-d')) + ->where('is_skipped', true) + ->etc() + ) + ) + ->where('errors', null) + ); + + $schedule->refresh(); + $this->assertTrue($schedule->is_skipped); + $schedule->scheduledUserDishes->each(fn ($scheduledUserDish) => $this->assertNull($scheduledUserDish->dish_id)); + } +} diff --git a/backend/tests/Feature/ScheduledUserDish/CreateScheduledUserDishTest.php b/backend/tests/Feature/ScheduledUserDish/CreateScheduledUserDishTest.php new file mode 100644 index 0000000..3acf60b --- /dev/null +++ b/backend/tests/Feature/ScheduledUserDish/CreateScheduledUserDishTest.php @@ -0,0 +1,121 @@ +planner; + $userOne = User::factory()->planner($planner)->create(); + $userTwo = User::factory()->planner($planner)->create(); + $users = collect([$userOne, $userTwo]); + + $dish = Dish::factory()->planner($planner)->create(); + $dish->users()->attach($users); + $scheduleDate = '2025-12-13'; + + $targetUserDish = $dish->userDishes->random(); + + $this->assertDatabaseEmpty(Schedule::class); + $this->assertDatabaseEmpty(ScheduledUserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.scheduled-user-dishes.store'), [ + 'user_dish_id' => $targetUserDish->id, + 'date' => $scheduleDate, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('scheduled_user_dish', fn ($json) => $json + ->has('id') + ->where('is_skipped', null) + ->has('schedule', fn (AssertableJson $json) => $json + ->has('id') + ->where('date', $scheduleDate) + ) + ->has('userDish', fn (AssertableJson $json) => $json + ->where('id', $targetUserDish->id) + ->has('user', fn (AssertableJson $json) => $json + ->where('id', $targetUserDish->user->id) + ->where('name', $targetUserDish->user->name) + ) + ->has('dish', fn (AssertableJson $json) => $json + ->where('id', $targetUserDish->dish->id) + ->where('planner_id', $targetUserDish->dish->planner_id) + ->where('name', $targetUserDish->dish->name) + ->has('users', 2) + ) + ->has('recurrences') + ) + ) + ) + ->where('errors', null) + ); + + + $this->assertDatabaseCount(Schedule::class, 1); + $this->assertDatabaseHas(Schedule::class, [ + 'date' => $scheduleDate, + ]); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + $this->assertDatabaseHas(ScheduledUserDish::class, [ + 'user_dish_id' => $targetUserDish->id, + 'schedule_id' => Schedule::all()->first()->id, + ]); + } + + public function test_planner_cannot_schedule_user_dishes_from_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $userOne = User::factory()->planner($otherPlanner)->create(); + $userTwo = User::factory()->planner($otherPlanner)->create(); + $users = collect([$userOne, $userTwo]); + + $dish = Dish::factory()->planner($otherPlanner)->create(); + $dish->users()->attach($users); + $scheduleDate = '2025-12-13'; + + $targetUserDish = $dish->userDishes->random(); + + $this->assertDatabaseEmpty(Schedule::class); + $this->assertDatabaseEmpty(ScheduledUserDish::class); + + $this + ->actingAs($planner) + ->post(route('api.scheduled-user-dishes.store'), [ + 'user_dish_id' => $targetUserDish->id, + 'date' => $scheduleDate, + ]) + ->assertStatus(403) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->whereNull('payload') + ->where('errors', [ + "This action is unauthorized." + ]) + ); + + + $this->assertDatabaseEmpty(Schedule::class); + $this->assertDatabaseEmpty(ScheduledUserDish::class); + } +} diff --git a/backend/tests/Feature/ScheduledUserDish/DeleteScheduledUserDishTest.php b/backend/tests/Feature/ScheduledUserDish/DeleteScheduledUserDishTest.php new file mode 100755 index 0000000..22b0486 --- /dev/null +++ b/backend/tests/Feature/ScheduledUserDish/DeleteScheduledUserDishTest.php @@ -0,0 +1,82 @@ +planner; + $userOne = User::factory()->planner($planner)->create(); + $userTwo = User::factory()->planner($planner)->create(); + $users = collect([$userOne, $userTwo]); + + $dish = Dish::factory()->planner($planner)->create(); + $dish->users()->attach($users); + $schedule = Schedule::factory()->planner($planner)->create(); + $scheduledUserDish = ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($dish->userDishes->random()) + ->create(); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.scheduled-user-dishes.destroy', $scheduledUserDish)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseEmpty(ScheduledUserDish::class); + } + + public function test_planner_cannot_delete_a_scheduled_dish_of_another_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $userOne = User::factory()->planner($otherPlanner)->create(); + $userTwo = User::factory()->planner($otherPlanner)->create(); + $users = collect([$userOne, $userTwo]); + + $dish = Dish::factory()->planner($otherPlanner)->create(); + $dish->users()->attach($users); + $schedule = Schedule::factory()->planner($otherPlanner)->create(); + $scheduledUserDish = ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($dish->userDishes->random()) + ->create(); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.scheduled-user-dishes.destroy', $scheduledUserDish)) + ->assertStatus(403) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', [ + "This action is unauthorized." + ]) + ); + + $this->assertDatabaseCount(ScheduledUserDish::class, 1); + } +} diff --git a/backend/tests/Feature/ScheduledUserDish/ReadScheduledUserDishTest.php b/backend/tests/Feature/ScheduledUserDish/ReadScheduledUserDishTest.php new file mode 100644 index 0000000..8c44ec5 --- /dev/null +++ b/backend/tests/Feature/ScheduledUserDish/ReadScheduledUserDishTest.php @@ -0,0 +1,130 @@ +planner; + $userOne = User::factory()->planner($planner)->create(['name' => 'Melissa']); + $userTwo = User::factory()->planner($planner)->create(['name' => 'Jochen']); + + $users = collect([$userOne, $userTwo]); + $dishes = Dish::factory()->planner($planner)->count(rand(15, 20))->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->sync($users->pluck('id')->toArray())); + + $dateRangeStart = '2024-01-01'; + $dateRangeEnd = '2024-01-07'; + + $period = CarbonPeriod::create($dateRangeStart, $dateRangeEnd); + + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + + $users->each(function (User $user) use ($schedule) { + $randomUserDish = $user->userDishes->random(); + + return ScheduledUserDish::factory() + ->schedule($schedule) + ->user($randomUserDish->user) + ->userDish($randomUserDish) + ->create(); + }); + } + + $randomScheduledUserDish = ScheduledUserDish::all()->random(); + + $this + ->actingAs($planner) + ->get(route('api.scheduled-user-dishes.show', $randomScheduledUserDish)) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('scheduled_user_dish', fn (AssertableJson $json) => $json + ->where('id', $randomScheduledUserDish->id) + ->where('is_skipped', false) + ->has('schedule', fn (AssertableJson $json) => $json + ->has('id') + ->where('date', $randomScheduledUserDish->schedule->date->format('Y-m-d')) + ) + ->has('userDish', fn (AssertableJson $json) => $json + ->has('id') + ->has('user', fn (AssertableJson $json) => $json + ->where('id', $randomScheduledUserDish->userDish->user->id) + ->where('name', $randomScheduledUserDish->userDish->user->name) + ) + ->has('dish', fn (AssertableJson $json) => $json + ->where('id', $randomScheduledUserDish->userDish->dish->id) + ->where('planner_id', $planner->id) + ->where('name', $randomScheduledUserDish->userDish->dish->name) + ->has('users', 2) + ) + ->has('recurrences') + ) + ) + ) + ->where('errors', null) + ); + } + + public function test_planner_cannot_read_scheduled_user_dish_from_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $userOne = User::factory()->planner($otherPlanner)->create(['name' => 'Melissa']); + $userTwo = User::factory()->planner($otherPlanner)->create(['name' => 'Jochen']); + + $users = collect([$userOne, $userTwo]); + $dishes = Dish::factory()->planner($otherPlanner)->count(rand(15, 20))->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->sync($users->pluck('id')->toArray())); + + $dateRangeStart = '2024-01-01'; + $dateRangeEnd = '2024-01-07'; + + $period = CarbonPeriod::create($dateRangeStart, $dateRangeEnd); + + foreach ($period as $date) { + $schedule = Schedule::factory()->planner($otherPlanner)->date($date)->create(); + + $users->each(function (User $user) use ($schedule) { + $randomUserDish = $user->userDishes->random(); + + return ScheduledUserDish::factory() + ->schedule($schedule) + ->user($randomUserDish->user) + ->userDish($randomUserDish) + ->create(); + }); + } + + $randomScheduledUserDish = ScheduledUserDish::all()->random(); + + $this + ->actingAs($planner) + ->get(route('api.scheduled-user-dishes.show', $randomScheduledUserDish)) + ->assertStatus(403) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', [ + "This action is unauthorized." + ]) + ); + } +} diff --git a/backend/tests/Feature/ScheduledUserDish/UpdateScheduledUserDishTest.php b/backend/tests/Feature/ScheduledUserDish/UpdateScheduledUserDishTest.php new file mode 100644 index 0000000..580055a --- /dev/null +++ b/backend/tests/Feature/ScheduledUserDish/UpdateScheduledUserDishTest.php @@ -0,0 +1,172 @@ +planner; + $userOne = User::factory()->planner($planner)->create(); + $userTwo = User::factory()->planner($planner)->create(); + $users = collect([$userOne, $userTwo]); + + $this->generateDishes($planner); + $this->generateScheduledDishes($planner); + + $dishOne = Dish::factory()->planner($planner)->create(); + $dishOne->users()->sync($users); + $dishTwo = Dish::factory()->planner($planner)->create(); + $dishTwo->users()->sync($users); + + $targetUser = $users->pop(); + $oldUserDish = $targetUser->userDishes()->inRandomOrder()->first(); + $newUserDish = $dishTwo->userDishes()->inRandomOrder()->first(); + + $schedule = Schedule::factory()->planner($planner)->create(); + $randomScheduledUserDish = ScheduledUserDish::factory() + ->schedule($schedule) + ->user($oldUserDish->user) + ->userDish($oldUserDish) + ->create(); + + $this + ->actingAs($planner) + ->put(route('api.scheduled-user-dishes.update', $randomScheduledUserDish), [ + 'user_dish_id' => $newUserDish->id, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('scheduled_user_dish', fn (AssertableJson $json) => $json + ->where('id', $randomScheduledUserDish->id) + ->where('is_skipped', false) + ->has('schedule', fn (AssertableJson $json) => $json + ->has('id') + ->where('date', $randomScheduledUserDish->schedule->date->format('Y-m-d')) + ) + ->has('userDish', fn (AssertableJson $json) => $json + ->where('id', $newUserDish->id) + ->has('user', fn (AssertableJson $json) => $json + ->where('id', $newUserDish->user->id) + ->where('name', $newUserDish->user->name) + ) + ->has('dish', fn (AssertableJson $json) => $json + ->where('id', $newUserDish->dish->id) + ->where('planner_id', $planner->id) + ->where('name', $newUserDish->dish->name) + ->has('users', 2) + ) + ->has('recurrences') + ) + ) + ) + ->where('errors', null) + ); + } + + public function test_planner_cannot_update_dish_of_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $userOne = User::factory()->planner($otherPlanner)->create(); + $userTwo = User::factory()->planner($otherPlanner)->create(); + $users = collect([$userOne, $userTwo]); + + $this->generateDishes($otherPlanner); + $this->generateScheduledDishes($otherPlanner); + + $dishOne = Dish::factory()->planner($otherPlanner)->create(); + $dishOne->users()->sync($users); + $dishTwo = Dish::factory()->planner($otherPlanner)->create(); + $dishTwo->users()->sync($users); + + $targetUser = $users->pop(); + $oldUserDish = $targetUser->userDishes()->inRandomOrder()->first(); + $newUserDish = $dishTwo->userDishes()->inRandomOrder()->first(); + + $schedule = Schedule::factory()->planner($otherPlanner)->create(); + $randomScheduledUserDish = ScheduledUserDish::factory() + ->schedule($schedule) + ->user($oldUserDish->user) + ->userDish($oldUserDish) + ->create(); + + $this + ->actingAs($planner) + ->put(route('api.scheduled-user-dishes.update', $randomScheduledUserDish), [ + 'user_dish_id' => $newUserDish->id, + ]) + ->assertStatus(403) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', [ + "This action is unauthorized." + ]) + ); + } + + public function test_is_skipped_update_succeeds(): void + { + $planner = $this->planner; + $userOne = User::factory()->planner($planner)->create(); + $userTwo = User::factory()->planner($planner)->create(); + $users = collect([$userOne, $userTwo]); + + $this->generateDishes($planner); + $this->generateScheduledDishes($planner); + + $dishOne = Dish::factory()->planner($planner)->create(); + $dishOne->users()->sync($users); + $dishTwo = Dish::factory()->planner($planner)->create(); + $dishTwo->users()->sync($users); + $schedule = Schedule::factory()->planner($planner)->create(); + $randomUser = $users->random(); + $randomScheduledUserDish = ScheduledUserDish::factory() + ->schedule($schedule) + ->user($randomUser) + ->userDish($randomUser->userDishes->random()) + ->create(); + + $this + ->actingAs($planner) + ->put(route('api.scheduled-user-dishes.update', $randomScheduledUserDish), [ + 'is_skipped' => true, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('scheduled_user_dish', fn (AssertableJson $json) => $json + ->where('id', $randomScheduledUserDish->id) + ->where('userDish', null) + ->where('is_skipped', true) + ->has('schedule', fn (AssertableJson $json) => $json + ->has('id') + ->where('date', $randomScheduledUserDish->schedule->date->format('Y-m-d')) + ) + ) + ) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/User/CreateUserTest.php b/backend/tests/Feature/User/CreateUserTest.php new file mode 100644 index 0000000..3f3ce11 --- /dev/null +++ b/backend/tests/Feature/User/CreateUserTest.php @@ -0,0 +1,41 @@ +planner; + $newUserName = fake()->name; + + $this->assertDatabaseEmpty(User::class); + + $this + ->actingAs($planner) + ->post(route('api.users.create'), [ + 'name' => $newUserName, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user', fn ($json) => $json + ->has('id') + ->where('name', $newUserName) + ) + ) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/User/DeleteUserTest.php b/backend/tests/Feature/User/DeleteUserTest.php new file mode 100644 index 0000000..3526de9 --- /dev/null +++ b/backend/tests/Feature/User/DeleteUserTest.php @@ -0,0 +1,52 @@ +planner; + $user = User::factory()->planner($planner)->create(); + + $this + ->actingAs($planner) + ->delete(route('api.users.delete', $user)) + ->assertStatus(201) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + } + + public function test_planner_cannot_update_user_of_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $newUserName = fake()->name; + $user = User::factory()->planner($otherPlanner)->create(); + + $this + ->actingAs($planner) + ->put(route('api.users.update', $user), [ + 'name' => $newUserName, + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ["MODEL_NOT_FOUND"]) + ); + } +} diff --git a/backend/tests/Feature/User/Dish/ListUserDishesTest.php b/backend/tests/Feature/User/Dish/ListUserDishesTest.php new file mode 100644 index 0000000..5b12ab4 --- /dev/null +++ b/backend/tests/Feature/User/Dish/ListUserDishesTest.php @@ -0,0 +1,60 @@ +planner; + $users = User::factory()->planner($planner)->count(10)->create(); + $dishes = Dish::factory()->planner($planner)->count(10)->create(); + $users->each(function ($user) use ($dishes) { + $user->dishes()->attach($dishes->random(5)); + }); + + $this + ->actingAs($planner) + ->get(route('api.user-dishes.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload.user_dishes', 10 * 5) + ->where('errors', null) + ); + } + + public function test_planner_cannot_see_user_dishes_from_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $users = User::factory()->planner($planner)->count(10)->create(); + $dishes = Dish::factory()->planner($planner)->count(10)->create(); + $users->each(function ($user) use ($dishes) { + $user->dishes()->attach($dishes->random(rand(1, 5))); + }); + + $this + ->actingAs($otherPlanner) + ->get(route('api.user-dishes.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload.user_dishes', 0) + ->where('errors', null) + ); + } + +} diff --git a/backend/tests/Feature/User/Dish/RemoveDishesForUserTest.php b/backend/tests/Feature/User/Dish/RemoveDishesForUserTest.php new file mode 100755 index 0000000..a04ced3 --- /dev/null +++ b/backend/tests/Feature/User/Dish/RemoveDishesForUserTest.php @@ -0,0 +1,47 @@ +assertDatabaseEmpty(UserDish::class); + + $planner = $this->planner; + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this->assertDatabaseCount(UserDish::class, 1); + + $this + ->actingAs($planner) + ->delete(route('api.users.dishes.destroy', [ + 'dish' => $dish, + 'user' => $user + ]), []) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload', null) + ->where('errors', null) + ); + + $this->assertDatabaseEmpty(UserDish::class); + + $user->refresh(); + $this->assertEmpty($user->dishes); + } +} diff --git a/backend/tests/Feature/User/Dish/ShowUserDishTest.php b/backend/tests/Feature/User/Dish/ShowUserDishTest.php new file mode 100644 index 0000000..02cdb5b --- /dev/null +++ b/backend/tests/Feature/User/Dish/ShowUserDishTest.php @@ -0,0 +1,78 @@ +planner; + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $userDish = $user->userDishes->first(); + + $this + ->actingAs($planner) + ->get(route('api.users.dishes.show', [ + 'user' => $user->id, + 'dish' => $dish->id, + ])) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->where('id', $userDish->id) + ->has('user', fn ($json) => $json + ->where('id', $user->id) + ->where('name', $user->name) + ) + ->has('dish', fn ($json) => $json + ->where('id', $dish->id) + ->where('planner_id', $planner->id) + ->where('name', $dish->name) + ->has('users') + ) + ->where('recurrences', []) + ) + ) + ->where('errors', null) + ); + } + + public function test_planner_cannot_see_user_dish_of_other_user(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $user = User::factory()->planner($otherPlanner)->create(); + $dish = Dish::factory()->planner($otherPlanner)->create(); + $user->dishes()->attach($dish); + + $this + ->actingAs($planner) + ->get(route('api.users.dishes.show', [ + 'user' => $user->id, + 'dish' => $dish->id, + ])) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + } +} diff --git a/backend/tests/Feature/User/Dish/StoreRecurrenceForUserDishTest.php b/backend/tests/Feature/User/Dish/StoreRecurrenceForUserDishTest.php new file mode 100755 index 0000000..ec04b68 --- /dev/null +++ b/backend/tests/Feature/User/Dish/StoreRecurrenceForUserDishTest.php @@ -0,0 +1,272 @@ +planner; + $recurrenceType = WeeklyRecurrence::class; + $recurrenceValue = WeekdaysEnum::Thursday->value; + + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this + ->actingAs($planner) + ->post(route('api.users.dishes.recurrences.store', [ + 'dish' => $dish, + 'user' => $user, + ]), [ + [ + 'type' => $recurrenceType, + 'value' => $recurrenceValue, + ], + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->has('id') + ->where('dish.name', $dish->name) + ->has('user', fn ($json) => $json + ->where('id', $user->id) + ->where('name', $user->name) + ) + ->has('recurrences', 1) + ->has('recurrences.0', fn ($json) => $json + ->has('id') + ->where('type', $recurrenceType) + ->where('value', $recurrenceValue) + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(WeeklyRecurrence::class, 1); + } + + public function test_it_adds_minimum_recurrence_to_user_dish(): void + { + $planner = $this->planner; + $recurrenceType = MinimumRecurrence::class; + $recurrenceValue = 5; + + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this + ->actingAs($planner) + ->post(route('api.users.dishes.recurrences.store', [ + 'dish' => $dish, + 'user' => $user + ]), [ + [ + 'type' => $recurrenceType, + 'value' => $recurrenceValue, + ] + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->has('id') + ->where('dish.id', $dish->id) + ->where('dish.planner_id', $dish->planner_id) + ->where('dish.name', $dish->name) + ->where('user.id', $user->id) + ->where('user.name', $user->name) + ->has('recurrences', 1) + ->has('recurrences.0', fn ($json) => $json + ->has('id') + ->where('type', $recurrenceType) + ->where('value', $recurrenceValue) + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(MinimumRecurrence::class, 1); + } + + public function test_it_adds_multiple_recurrences_to_user_dish(): void + { + $planner = $this->planner; + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this + ->actingAs($planner) + ->post(route('api.users.dishes.recurrences.store', [ + 'dish' => $dish, + 'user' => $user + ]), [ + [ + 'type' => MinimumRecurrence::class, + 'value' => 5, + ], + [ + 'type' => WeeklyRecurrence::class, + 'value' => WeekdaysEnum::Thursday->value, + ], + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->has('id') + ->where('dish.id', $dish->id) + ->where('dish.planner_id', $dish->planner_id) + ->where('dish.name', $dish->name) + ->where('user.id', $user->id) + ->where('user.name', $user->name) + ->has('recurrences', 2) + ->has('recurrences.0', fn ($json) => $json + ->has('id') + ->where('type', MinimumRecurrence::class) + ->where('value', 5) + ) + ->has('recurrences.1', fn ($json) => $json + ->has('id') + ->where('type', WeeklyRecurrence::class) + ->where('value', WeekdaysEnum::Thursday->value) + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(MinimumRecurrence::class, 1); + $this->assertDatabaseHas(MinimumRecurrence::class, [ + 'days' => 5, + ]); + $this->assertDatabaseCount(WeeklyRecurrence::class, 1); + $this->assertDatabaseHas(WeeklyRecurrence::class, [ + 'weekday' => WeekdaysEnum::Thursday->value, + ]); + } + + public function test_it_removes_all_recurrences(): void + { + $planner = $this->planner; + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $user->dishes()->attach($dish); + + $this + ->actingAs($planner) + ->post(route('api.users.dishes.recurrences.store', [ + 'dish' => $dish, + 'user' => $user + ]), []) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->has('id') + ->where('dish.id', $dish->id) + ->where('dish.planner_id', $dish->planner_id) + ->where('dish.name', $dish->name) + ->where('user.id', $user->id) + ->where('user.name', $user->name) + ->where('recurrences', []) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseEmpty(MinimumRecurrence::class); + $this->assertDatabaseEmpty(WeeklyRecurrence::class); + } + + public function test_it_removes_other_recurrences_to_user_dish(): void + { + $planner = $this->planner; + MinimumRecurrence::query()->truncate(); + WeeklyRecurrence::query()->truncate(); + UserDishRecurrence::query()->truncate(); + + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + + $userDish = UserDish::factory()->create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + + $recurrence = MinimumRecurrence::factory()->create([ + 'days' => 5, + ]); + + UserDishRecurrence::factory()->create([ + 'user_dish_id' => $userDish->id, + 'recurrence_id' => $recurrence->id, + 'recurrence_type' => MinimumRecurrence::class, + ]); + + $this->assertDatabaseCount(MinimumRecurrence::class, 1); + + $this + ->actingAs($planner) + ->post(route('api.users.dishes.recurrences.store', [ + 'dish' => $dish, + 'user' => $user + ]), [ + [ + 'type' => WeeklyRecurrence::class, + 'value' => WeekdaysEnum::Thursday->value, + ], + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user_dish', fn ($json) => $json + ->has('id') + ->where('dish.id', $dish->id) + ->where('dish.name', $dish->name) + ->where('user.id', $user->id) + ->where('user.name', $user->name) + ->has('recurrences', 1) + ->has('recurrences.0', fn ($json) => $json + ->has('id') + ->where('type', WeeklyRecurrence::class) + ->where('value', WeekdaysEnum::Thursday->value) + ) + ) + ) + ->where('errors', null) + ); + + $this->assertDatabaseCount(MinimumRecurrence::class, 0); + $this->assertDatabaseCount(WeeklyRecurrence::class, 1); + $this->assertDatabaseHas(WeeklyRecurrence::class, [ + 'weekday' => WeekdaysEnum::Thursday->value, + ]); + } +} diff --git a/backend/tests/Feature/User/ListUsersTest.php b/backend/tests/Feature/User/ListUsersTest.php new file mode 100644 index 0000000..c2b694d --- /dev/null +++ b/backend/tests/Feature/User/ListUsersTest.php @@ -0,0 +1,73 @@ +planner; + $dishes = Dish::factory()->planner($planner)->count(rand(80, 100))->create(); + $users = User::factory()->planner($planner)->count(rand(2, 10))->create(); + $users->each(fn (User $user) => UserDish::factory() + ->user($user) + ->dish($dishes->random()) + ->create()); + + $this + ->actingAs($planner) + ->get(route('api.users.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->where('users', $users + ->sortBy('id') + ->map(fn (User $user) => [ + 'id' => $user->id, + 'name' => $user->name, + 'user_dishes' => $user->userDishes->map(fn (UserDish $userDish) => [ + 'id' => $userDish->id, + 'dish' => [ + 'id' => $userDish->dish->id, + 'name' => $userDish->dish->name, + ], + 'recurrences' => [], + ])->toArray(), + ]) + ->toArray() + ) + ) + ->where('errors', null) + ); + } + + public function test_user_cannot_see_list_of_users_of_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + User::factory()->planner($otherPlanner)->count(rand(2, 10))->create(); + + $this + ->actingAs($planner) + ->get(route('api.users.index')) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->where('payload.users', []) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/User/ShowUserTest.php b/backend/tests/Feature/User/ShowUserTest.php new file mode 100644 index 0000000..acc9f32 --- /dev/null +++ b/backend/tests/Feature/User/ShowUserTest.php @@ -0,0 +1,54 @@ +planner; + $user = User::factory()->planner($planner)->create(); + + $this + ->actingAs($planner) + ->get(route('api.users.show', ['user' => $user])) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user', fn ($json) => $json + ->where('id', $user->id) + ->where('name', $user->name) + ) + ) + ->where('errors', null) + ); + } + + public function test_planner_cannot_see_user_from_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $user = User::factory()->planner($otherPlanner)->create(); + + $this + ->actingAs($planner) + ->get(route('api.users.show', ['user' => $user])) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ['MODEL_NOT_FOUND']) + ); + } +} diff --git a/backend/tests/Feature/User/ShowUserWithDishesTest.php b/backend/tests/Feature/User/ShowUserWithDishesTest.php new file mode 100644 index 0000000..09a9b67 --- /dev/null +++ b/backend/tests/Feature/User/ShowUserWithDishesTest.php @@ -0,0 +1,59 @@ +planner; + $users = User::factory()->planner($planner)->count(rand(3, 10))->create(); + $dishes = Dish::factory()->planner($planner)->count(rand(3, 10))->create(); + $dishes->each(function (Dish $dish) use ($users) { + $dish->users()->sync($users->random(rand(1, 3))->pluck('id')); + }); + + $targetUser = $users->random(); + $targetUser->refresh(); + + $this + ->actingAs($planner) + ->get(route('api.users.dishes.index', [ + 'user' => $targetUser->id, + ])) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user', fn ($json) => $json + ->where('id', $targetUser->id) + ->where('name', $targetUser->name) + ->has('user_dishes', $targetUser->dishes->count()) + ->where('user_dishes', $targetUser->userDishes + ->map(fn (UserDish $userDish) => [ + 'id' => $userDish->id, + 'dish' => [ + 'id' => $userDish->dish->id, + 'name' => $userDish->dish->name, + ], + 'recurrences' => [], + ]) + ->toArray() + ) + ) + ) + ->where('errors', null) + ); + } +} diff --git a/backend/tests/Feature/User/UpdateUserTest.php b/backend/tests/Feature/User/UpdateUserTest.php new file mode 100644 index 0000000..429a240 --- /dev/null +++ b/backend/tests/Feature/User/UpdateUserTest.php @@ -0,0 +1,60 @@ +planner; + $newUserName = fake()->name; + $user = User::factory()->planner($planner)->create(); + + $this + ->actingAs($planner) + ->put(route('api.users.update', $user), [ + 'name' => $newUserName, + ]) + ->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', true) + ->has('payload', fn ($json) => $json + ->has('user', fn ($json) => $json + ->where('id', $user->id) + ->where('name', $newUserName) + ) + ) + ->where('errors', null) + ); + } + + public function test_planner_cannot_update_user_of_other_planner(): void + { + $planner = $this->planner; + $otherPlanner = Planner::factory()->create(); + $newUserName = fake()->name; + $user = User::factory()->planner($otherPlanner)->create(); + + $this + ->actingAs($planner) + ->put(route('api.users.update', $user), [ + 'name' => $newUserName, + ]) + ->assertStatus(404) + ->assertJson(fn (AssertableJson $json) => $json + ->where('success', false) + ->where('payload', null) + ->where('errors', ["MODEL_NOT_FOUND"]) + ); + } +} diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +planner($planner)->count($count)->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->sync($users->pluck('id')->toArray())); + + return $dishes; + } +} diff --git a/backend/tests/Traits/HasPlanner.php b/backend/tests/Traits/HasPlanner.php new file mode 100644 index 0000000..995e214 --- /dev/null +++ b/backend/tests/Traits/HasPlanner.php @@ -0,0 +1,20 @@ +create(); + + $this->planner = $planner; + } + +} diff --git a/backend/tests/Traits/ScheduledDishesTestTrait.php b/backend/tests/Traits/ScheduledDishesTestTrait.php new file mode 100644 index 0000000..0690289 --- /dev/null +++ b/backend/tests/Traits/ScheduledDishesTestTrait.php @@ -0,0 +1,40 @@ +generateDishes($planner); + + if (is_null($period)) { + $dateRangeStart = now(); + $dateRangeEnd = now()->addWeeks(2); + + $period = CarbonPeriod::create($dateRangeStart, $dateRangeEnd); + } + + collect($period) + ->each(function ($date) use ($users, $planner) { + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + + $users + ->each(fn (User $user) => ScheduledUserDish::factory() + ->schedule($schedule) + ->user($user) + ->userDish($user->userDishes->random()) + ->create() + ); + }); + } +} diff --git a/backend/tests/Unit/Actions/RegenerateScheduleDayActionTest.php b/backend/tests/Unit/Actions/RegenerateScheduleDayActionTest.php new file mode 100644 index 0000000..20e8f66 --- /dev/null +++ b/backend/tests/Unit/Actions/RegenerateScheduleDayActionTest.php @@ -0,0 +1,36 @@ +planner; + User::factory()->planner($planner)->count(10)->create(); + $this->generateDishes($planner); + + $schedule = Schedule::factory()->planner($planner)->create(); + + $this->assertEmpty($schedule->scheduledUserDishes); + + $mockAction = $this->mock(RegenerateScheduleDayForUserAction::class); + $mockAction->shouldReceive('execute')->times(10); + + + resolve(RegenerateScheduleDayAction::class)->execute($planner, $schedule, true); + } +} diff --git a/backend/tests/Unit/Actions/RegenerateScheduleDayForUserActionTest.php b/backend/tests/Unit/Actions/RegenerateScheduleDayForUserActionTest.php new file mode 100644 index 0000000..4d1f670 --- /dev/null +++ b/backend/tests/Unit/Actions/RegenerateScheduleDayForUserActionTest.php @@ -0,0 +1,103 @@ +planner; + $dishes = Dish::factory()->planner($planner)->count(30)->create(); + $schedule = Schedule::factory()->planner($planner)->date($date)->create(); + $user = User::factory()->planner($planner)->create(); + $user->dishes()->attach($dishes); + + $this->assertEmpty($schedule->scheduledUserDishes); + + + resolve(RegenerateScheduleDayForUserAction::class)->execute($planner, $schedule, $user, true); + + + $expectedSchedule = Schedule::where('date', $date->format('Y-m-d'))->first(); + $this->assertCount(1, $expectedSchedule->scheduledUserDishes); + + $scheduledUserDish = ScheduledUserDish::all()->first(); + $this->assertNotNull($scheduledUserDish); + $this->assertNotNull($scheduledUserDish->userDish->id); + } + + public function test_it_updates_if_overwrite_is_true(): void + { + $planner = $this->planner; + /** @var Collection $dishes */ + $dishes = Dish::factory()->planner($planner)->count(30)->create(); + $schedule = Schedule::factory()->planner($planner)->create(); + $user = User::factory()->planner($planner)->create(); + $user->dishes()->attach($dishes); + + $startingDish = $user->userDishes->random(); + + ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($startingDish) + ->create(); + + $this->assertCount(1, $schedule->refresh()->scheduledUserDishes); + + + resolve(RegenerateScheduleDayForUserAction::class)->execute($planner, $schedule, $user, true); + + + $schedule->refresh(); + $this->assertCount(1, $schedule->scheduledUserDishes); + + $scheduledUserDish = ScheduledUserDish::all()->first(); + $this->assertNotNull($scheduledUserDish); + $this->assertNotNull($scheduledUserDish->userDish); + $this->assertNotEquals($startingDish->id, $scheduledUserDish->userDish->id); // TODO Flaky test + } + + public function test_it_does_not_update_if_overwrite_is_false(): void + { + $planner = $this->planner; + /** @var Collection $dishes */ + $dishes = Dish::factory()->planner($planner)->count(30)->create(); + $schedule = Schedule::factory()->planner($planner)->create(); + $user = User::factory()->planner($planner)->create(); + $user->dishes()->attach($dishes); + + $startingUserDish = $user->userDishes->random(); + + ScheduledUserDish::factory() + ->schedule($schedule) + ->userDish($startingUserDish) + ->create(); + + $this->assertCount(1, $schedule->refresh()->scheduledUserDishes); + + + resolve(RegenerateScheduleDayForUserAction::class)->execute($planner, $schedule, $user, false); + + + $schedule->refresh(); + $this->assertCount(1, $schedule->scheduledUserDishes); + + $scheduledUserDish = ScheduledUserDish::all()->first(); + $this->assertNotNull($scheduledUserDish); + $this->assertEquals($startingUserDish->id, $scheduledUserDish->userDish->id); + } +} diff --git a/backend/tests/Unit/Schedule/Actions/DraftScheduleForDateActionTest.php b/backend/tests/Unit/Schedule/Actions/DraftScheduleForDateActionTest.php new file mode 100644 index 0000000..451b590 --- /dev/null +++ b/backend/tests/Unit/Schedule/Actions/DraftScheduleForDateActionTest.php @@ -0,0 +1,41 @@ +planner; + $users = User::factory()->planner($planner)->count(10)->create(); + $dishes = Dish::factory()->planner($planner)->count(10)->create(); + $dishes->each(fn (Dish $dish) => $dish->users()->attach($users)); + + $expectedScheduleCount = 1; + + $this->assertDatabaseCount(Schedule::class, 0); + + $schedule = Schedule::create([ + 'planner_id' => $planner->id, + 'date' => now()->addDay() + ]); + + resolve(DraftScheduleForDateAction::class)->execute($schedule); + + + $this->assertDatabaseCount(Schedule::class, $expectedScheduleCount); + $this->assertDatabaseCount(ScheduledUserDish::class, $expectedScheduleCount * User::all()->count()); + } +} diff --git a/backend/tests/Unit/Schedule/Actions/DraftScheduleForPeriodActionTest.php b/backend/tests/Unit/Schedule/Actions/DraftScheduleForPeriodActionTest.php new file mode 100644 index 0000000..52669ed --- /dev/null +++ b/backend/tests/Unit/Schedule/Actions/DraftScheduleForPeriodActionTest.php @@ -0,0 +1,40 @@ +planner; + + $this->assertNotNull($planner); + + $expectedPeriodScheduleCount = 10; + $this->generateDishes($planner); + + $this->assertDatabaseCount(Schedule::class, 0); + + + resolve(DraftScheduleForPeriodAction::class) + ->execute($planner, CarbonPeriod::create(now()->addDay(), now()->addDays($expectedPeriodScheduleCount))); + + + $this->assertDatabaseCount(Schedule::class, $expectedPeriodScheduleCount); + $this->assertDatabaseCount(ScheduledUserDish::class, $expectedPeriodScheduleCount * User::all()->count()); + } +} diff --git a/backend/tests/Unit/Schedule/ScheduleGeneratorTest.php b/backend/tests/Unit/Schedule/ScheduleGeneratorTest.php new file mode 100644 index 0000000..bce3589 --- /dev/null +++ b/backend/tests/Unit/Schedule/ScheduleGeneratorTest.php @@ -0,0 +1,145 @@ +planner; + $user = User::factory()->planner($this->planner)->create(); + + $dishOne = Dish::factory()->planner($planner)->create(); + $dishTwo = Dish::factory()->planner($planner)->create(); + UserDish::factory()->user($user)->dish($dishOne)->create(); + UserDish::factory()->user($user)->dish($dishTwo)->create(); + + $this->assertDatabaseEmpty(Schedule::class); + + (new ScheduleGenerator())->generate($planner); + + $schedules = Schedule::all(); + $this->assertTrue($schedules->isNotEmpty()); + $this->assertCount(14, $schedules); + } + + public function test_it_takes_weekly_recurrences_into_account(): void + { + $recurringDay = now()->addDays(2); + + $planner = $this->planner; + $user = User::factory()->planner($this->planner)->create(); + $dishesPlain = Dish::factory()->planner($planner)->count(20)->create(); + $dishRecurring = Dish::factory()->planner($planner)->create(); + $weeklyRecurrence = WeeklyRecurrence::factory() + ->weekday(WeekdaysEnum::from($recurringDay->dayOfWeek())) + ->create(); + + $dishesPlain->each(fn (Dish $dish) => UserDish::factory() + ->user($user) + ->dish($dish) + ->create() + ); + + $userDishRecurring = UserDish::factory() + ->user($user) + ->dish($dishRecurring) + ->create(); + + UserDishRecurrence::factory()->create([ + 'user_dish_id' => $userDishRecurring->id, + 'recurrence_id' => $weeklyRecurrence->id, + 'recurrence_type' => WeeklyRecurrence::class, + ]); + + $userDishRecurring->refresh(); + $this->assertNotEmpty($userDishRecurring->recurrences); + + $this->assertDatabaseEmpty(Schedule::class); + + + (new ScheduleGenerator())->generate($planner); + + + $this->assertTrue(Schedule::all()->isNotEmpty()); + + $schedule = Schedule::query()->where('date', $recurringDay->format('Y-m-d'))->first(); + $this->assertNotNull($schedule); + $this->assertNotEmpty($schedule->scheduledUserDishes); + $this->assertEquals($schedule->scheduledUserDishes->first()->userDish->id, $userDishRecurring->id); + + $schedule = Schedule::query()->where('date', $recurringDay->addWeek()->format('Y-m-d'))->first(); + $this->assertNotNull($schedule); + $this->assertNotEmpty($schedule->scheduledUserDishes); + $this->assertEquals($schedule->scheduledUserDishes->first()->userDish->id, $userDishRecurring->id); + } + + public function test_it_takes_minimum_recurrences_into_account(): void + { + $recurringMinimum = 3; + + $planner = $this->planner; + $user = User::factory()->planner($this->planner)->create(); + $dishPlain = Dish::factory()->planner($planner)->create(); + $dishRecurring = Dish::factory()->planner($planner)->create(); + $minimumRecurrence = MinimumRecurrence::factory()->days($recurringMinimum)->create(); + UserDish::factory() + ->user($user) + ->dish($dishPlain) + ->create(); + $userDishRecurring = UserDish::factory() + ->user($user) + ->dish($dishRecurring) + ->create(); + + UserDishRecurrence::factory()->create([ + 'user_dish_id' => $userDishRecurring->id, + 'recurrence_id' => $minimumRecurrence->id, + 'recurrence_type' => MinimumRecurrence::class, + ]); + + $userDishRecurring->refresh(); + $this->assertNotEmpty($userDishRecurring->recurrences); + + $this->assertDatabaseEmpty(Schedule::class); + + + (new ScheduleGenerator())->generate($planner); + + + $this->assertTrue(Schedule::all()->isNotEmpty()); + + Schedule::all() + ->filter(fn (Schedule $schedule) => $schedule->scheduledUserDishes()->first()->userDish->dish->id === $dishRecurring->id) + ->map(fn (Schedule $schedule) => $schedule->date) + ->reduce(function (?Carbon $previousDate, Carbon $currentDate) use ($recurringMinimum) { + if (! is_null($previousDate)) { + $this->assertGreaterThanOrEqual( + $recurringMinimum, + $previousDate->diffInDays($currentDate), + 'Dates are not spaced properly' + ); + } + + return $currentDate; + }); + } +} diff --git a/backend/tests/Unit/ScheduleRepositoryTest.php b/backend/tests/Unit/ScheduleRepositoryTest.php new file mode 100644 index 0000000..600d2a5 --- /dev/null +++ b/backend/tests/Unit/ScheduleRepositoryTest.php @@ -0,0 +1,43 @@ +planner; + $date = Carbon::parse(fake()->date); + Schedule::factory()->planner($planner)->date($date)->create(); + + $this->assertDatabaseCount(Schedule::class, 1); + + $schedule = (new ScheduleRepository())->findOrCreate($planner, $date); + + $this->assertDatabaseCount(Schedule::class, 1); + $this->assertEquals($date, $schedule->date); + } + + public function test_find_or_create_creates_new_schedule_for_date(): void + { + $planner = $this->planner; + $date = Carbon::parse(fake()->date); + + $this->assertDatabaseEmpty(Schedule::class); + + $schedule = (new ScheduleRepository())->findOrCreate($planner, $date); + + $this->assertDatabaseCount(Schedule::class, 1); + $this->assertEquals($date, $schedule->date); + } +} diff --git a/backend/tests/Unit/UpdateScheduledUserDishActionTest.php b/backend/tests/Unit/UpdateScheduledUserDishActionTest.php new file mode 100644 index 0000000..b87f351 --- /dev/null +++ b/backend/tests/Unit/UpdateScheduledUserDishActionTest.php @@ -0,0 +1,35 @@ +create(); + $user = User::factory()->planner($planner)->create(); + $dish = Dish::factory()->planner($planner)->create(); + $otherDish = Dish::factory()->planner($planner)->create(); + $userDish = UserDish::factory()->user($user)->dish($dish)->create(); + $otherUserDish = UserDish::factory()->user($user)->dish($otherDish)->create(); + $schedule = Schedule::factory()->planner($planner)->create(); + $scheduledUserDish = ScheduledUserDish::factory()->schedule($schedule)->userDish($userDish)->create(); + + (new UpdateScheduledUserDishAction())->execute($scheduledUserDish, $otherUserDish); + + $scheduledUserDish->refresh(); + $this->assertEquals($otherUserDish->id, $scheduledUserDish->user_dish_id); + } +} diff --git a/backend/tests/Unit/UserDish/Repositories/UserDishRepositoryTest.php b/backend/tests/Unit/UserDish/Repositories/UserDishRepositoryTest.php new file mode 100644 index 0000000..efe5982 --- /dev/null +++ b/backend/tests/Unit/UserDish/Repositories/UserDishRepositoryTest.php @@ -0,0 +1,80 @@ +planner; + $user = User::factory()->planner($planner)->create(); + $dishRecurring = Dish::factory()->planner($planner)->create(); + $minimumRecurrence = MinimumRecurrence::factory()->days(5)->create(); + UserDish::factory()->user($user)->dish(Dish::factory()->planner($planner)->create())->create(); + $userDishRecurring = UserDish::factory()->user($user)->dish($dishRecurring)->create(); + + UserDishRecurrence::factory() + ->userDish($userDishRecurring) + ->recurrence($minimumRecurrence) + ->create(); + + $date = now()->startOfDay()->addDays(2); + + $schedule = Schedule::factory()->planner($planner)->date($date->copy()->addDays(2))->create(); + ScheduledUserDish::factory()->userDish($userDishRecurring)->schedule($schedule)->create(); + $this->actingAs($planner); + + + /** UserDishRepository $userDishRepository */ + $userDishRepository = resolve(UserDishRepository::class); + $userDishes = $userDishRepository->findInterferingUserDishes($user, $date); + + + $this->assertCount(1, $userDishes); + $this->assertEquals($userDishRecurring->id, $userDishes->first()->id); + } + + public function test_find_candidates_for_date(): void + { + $planner = $this->planner; + $user = User::factory()->planner($planner)->create(); + $dishRecurring = Dish::factory()->planner($planner)->create(); + $minimumRecurrence = MinimumRecurrence::factory()->days(5)->create(); + $userDishPlain = UserDish::factory()->user($user)->dish(Dish::factory()->planner($planner)->create())->create(); + $userDishRecurring = UserDish::factory()->user($user)->dish($dishRecurring)->create(); + + UserDishRecurrence::factory() + ->userDish($userDishRecurring) + ->recurrence($minimumRecurrence) + ->create(); + + $date = now()->startOfDay()->addDays(2); + + $schedule = Schedule::factory()->planner($planner)->date($date->copy()->addDays(2))->create(); + ScheduledUserDish::factory()->userDish($userDishRecurring)->schedule($schedule)->create(); + $this->actingAs($planner); + + + /** UserDishRepository $userDishRepository */ + $userDishRepository = resolve(UserDishRepository::class); + $userDishes = $userDishRepository->findCandidatesForDate($user, $date); + + + $this->assertEquals($userDishes->pluck('id')->toArray(), [$userDishPlain->id]); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..421b569 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + ], +}); diff --git a/frontend b/frontend deleted file mode 160000 index 71ea7c3..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 71ea7c3fe9f304fa60627b4b3c9c3a709cfee3ea diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..9b7c041 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..207bf93 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/frontend/LICENSE b/frontend/LICENSE new file mode 100644 index 0000000..577ff1d --- /dev/null +++ b/frontend/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + DishPlanner Copyright (C) 2025 myrmidex + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5c4780a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,87 @@ +# Welcome to React Router! + +A modern, production-ready template for building full-stack React applications using React Router. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) + +## Features + +- 🚀 Server-side rendering +- ⚡️ Hot Module Replacement (HMR) +- 📦 Asset bundling and optimization +- 🔄 Data loading and mutations +- 🔒 TypeScript by default +- 🎉 TailwindCSS for styling +- 📖 [React Router docs](https://reactrouter.com/) + +## Getting Started + +### Installation + +Install the dependencies: + +```bash +npm install +``` + +### Development + +Start the development server with HMR: + +```bash +npm run dev +``` + +Your application will be available at `http://localhost:5173`. + +## Building for Production + +Create a production build: + +```bash +npm run build +``` + +## Deployment + +### Docker Deployment + +To build and run using Docker: + +```bash +docker build -t my-app . + +# Run the container +docker run -p 3000:3000 my-app +``` + +The containerized application can be deployed to any platform that supports Docker, including: + +- AWS ECS +- Google Cloud Run +- Azure Container Apps +- Digital Ocean App Platform +- Fly.io +- Railway + +### DIY Deployment + +If you're familiar with deploying Node applications, the built-in app server is production-ready. + +Make sure to deploy the output of `npm run build` + +``` +├── package.json +├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. + +--- + +Built with ❤️ using React Router. diff --git a/frontend/app/app.css b/frontend/app/app.css new file mode 100644 index 0000000..99345d8 --- /dev/null +++ b/frontend/app/app.css @@ -0,0 +1,15 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx new file mode 100644 index 0000000..9fc6636 --- /dev/null +++ b/frontend/app/root.tsx @@ -0,0 +1,75 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +import type { Route } from "./+types/root"; +import "./app.css"; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts new file mode 100644 index 0000000..102b402 --- /dev/null +++ b/frontend/app/routes.ts @@ -0,0 +1,3 @@ +import { type RouteConfig, index } from "@react-router/dev/routes"; + +export default [index("routes/home.tsx")] satisfies RouteConfig; diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx new file mode 100644 index 0000000..398e47c --- /dev/null +++ b/frontend/app/routes/home.tsx @@ -0,0 +1,13 @@ +import type { Route } from "./+types/home"; +import { Welcome } from "../welcome/welcome"; + +export function meta({}: Route.MetaArgs) { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +} + +export default function Home() { + return ; +} diff --git a/frontend/app/welcome/logo-dark.svg b/frontend/app/welcome/logo-dark.svg new file mode 100644 index 0000000..dd82028 --- /dev/null +++ b/frontend/app/welcome/logo-dark.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/welcome/logo-light.svg b/frontend/app/welcome/logo-light.svg new file mode 100644 index 0000000..7328492 --- /dev/null +++ b/frontend/app/welcome/logo-light.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/welcome/welcome.tsx b/frontend/app/welcome/welcome.tsx new file mode 100644 index 0000000..8ac6e1d --- /dev/null +++ b/frontend/app/welcome/welcome.tsx @@ -0,0 +1,89 @@ +import logoDark from "./logo-dark.svg"; +import logoLight from "./logo-light.svg"; + +export function Welcome() { + return ( +
+
+
+
+ React Router + React Router +
+
+
+ +
+
+
+ ); +} + +const resources = [ + { + href: "https://reactrouter.com/docs", + text: "React Router Docs", + icon: ( + + + + ), + }, + { + href: "https://rmx.as/discord", + text: "Join Discord", + icon: ( + + + + ), + }, +]; diff --git a/frontend/archive/.dockerignore b/frontend/archive/.dockerignore new file mode 100644 index 0000000..11ee758 --- /dev/null +++ b/frontend/archive/.dockerignore @@ -0,0 +1 @@ +.env.local diff --git a/frontend/archive/.env.local.example b/frontend/archive/.env.local.example new file mode 100644 index 0000000..87516ef --- /dev/null +++ b/frontend/archive/.env.local.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_URL=http://localhost +#NODE_ENV=production \ No newline at end of file diff --git a/frontend/archive/.env.production b/frontend/archive/.env.production new file mode 100644 index 0000000..2fbb406 --- /dev/null +++ b/frontend/archive/.env.production @@ -0,0 +1,2 @@ +#NEXT_PUBLIC_API_URL=http://192.168.178.177:9000 +#NODE_ENV=local \ No newline at end of file diff --git a/frontend/archive/.gitignore b/frontend/archive/.gitignore new file mode 100644 index 0000000..b601ed9 --- /dev/null +++ b/frontend/archive/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +/.idea +/.env.local +/package-lock.json + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/archive/README.md b/frontend/archive/README.md new file mode 100644 index 0000000..da15af8 --- /dev/null +++ b/frontend/archive/README.md @@ -0,0 +1,2 @@ +# DishPlanner Front End + diff --git a/frontend/archive/bin/update.sh b/frontend/archive/bin/update.sh new file mode 100755 index 0000000..7eec906 --- /dev/null +++ b/frontend/archive/bin/update.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +echo "🔄 Pulling latest changes..." +git pull origin main + +echo "🔨 Installing dependencies..." +npm install + +echo "🏗️ Building frontend..." +npm run build + +echo "🔁 Restarting frontend service..." +sudo systemctl restart dishplanner-frontend + +echo "✅ Update complete!" diff --git a/frontend/archive/build_and_push.sh b/frontend/archive/build_and_push.sh new file mode 100755 index 0000000..ba9f2c6 --- /dev/null +++ b/frontend/archive/build_and_push.sh @@ -0,0 +1,3 @@ +#!/bin/bash +docker build -t 192.168.178.152:50114/dishplanner-frontend:latest . +docker push 192.168.178.152:50114/dishplanner-frontend:latest \ No newline at end of file diff --git a/frontend/archive/eslint.config.mjs b/frontend/archive/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/frontend/archive/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/frontend/archive/next.config.ts b/frontend/archive/next.config.ts new file mode 100644 index 0000000..536bef3 --- /dev/null +++ b/frontend/archive/next.config.ts @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + // Rewrite /api/* to backend container (from client-side) + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://backend:80/api/:path*", // internal Docker DNS + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/archive/package.json b/frontend/archive/package.json new file mode 100644 index 0000000..62e44db --- /dev/null +++ b/frontend/archive/package.json @@ -0,0 +1,33 @@ +{ + "name": "dish-planner", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "export": "next export" + }, + "dependencies": { + "@headlessui/react": "^2.2.0", + "@heroicons/react": "^2.2.0", + "classnames": "^2.5.1", + "luxon": "^3.5.0", + "next": "15.2.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/luxon": "^3.4.2", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.1.5", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/frontend/archive/postcss.config.mjs b/frontend/archive/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/frontend/archive/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/frontend/archive/public/dish-planner.webp b/frontend/archive/public/dish-planner.webp new file mode 100644 index 0000000..3eaa04f Binary files /dev/null and b/frontend/archive/public/dish-planner.webp differ diff --git a/frontend/archive/public/file.svg b/frontend/archive/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/archive/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/archive/public/globe.svg b/frontend/archive/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/archive/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/archive/public/next.svg b/frontend/archive/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/archive/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/archive/public/vercel.svg b/frontend/archive/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/archive/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/archive/public/window.svg b/frontend/archive/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/archive/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/archive/src/app/dishes/[id]/delete/page.tsx b/frontend/archive/src/app/dishes/[id]/delete/page.tsx new file mode 100644 index 0000000..95627e2 --- /dev/null +++ b/frontend/archive/src/app/dishes/[id]/delete/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import {useEffect, useState} from "react"; +import {use} from "react"; +import PageTitle from "@/components/ui/PageTitle"; +import {DishType} from "@/types/DishType"; +import {useRouter} from "next/navigation"; +import Link from "next/link"; +import Alert from "@/components/ui/Alert"; +import useRoutes from "@/hooks/useRoutes"; +import {deleteDish, fetchDish} from "@/utils/api/dishApi"; +import {UserType} from "@/types/UserType"; + +export default function EditDishPage({params}: { params: Promise<{ id: number }> }) { + const [name, setName] = useState(""); + const [recurrence, setRecurrence] = useState(0); + const [users, setUsers] = useState([]); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(true); // To handle loading state + const {id} = use(params) + const router = useRouter() + const routes = useRoutes(); + + useEffect(() => { + fetchDish(id) + .then((dish: DishType) => { + setName(dish.name); + setRecurrence(dish.recurrence); + setUsers(dish.users); + }) + .catch((err) => setError(err)) + .finally(() => setIsLoading(false)); + }, [id]); // Only run when `params.id` changes + + const submitForm = (e: React.MouseEvent) => { + e.preventDefault() + + deleteDish(id) + .then(() => router.push(routes.dish.index())) + .catch((err) => setError(err)) + } + + if (isLoading) { + return

Loading...

; + } + + return ( +
+
+ Delete Dish +
+ + { + error != '' && { error } + } + +
+
+ Are you sure you want to delete this dish? +
+
+ name: {name}
+ recurrence: {recurrence} + users: {users.map((user) => user.name).join(', ')} +
+ + +
+ No, take me back +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/archive/src/app/dishes/[id]/edit/page.tsx b/frontend/archive/src/app/dishes/[id]/edit/page.tsx new file mode 100644 index 0000000..364f9b1 --- /dev/null +++ b/frontend/archive/src/app/dishes/[id]/edit/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import {use, useCallback, useEffect, useState} from "react"; +import PageTitle from "@/components/ui/PageTitle"; +import EditDishForm from "@/components/features/dishes/EditDishForm"; +import {DishType} from "@/types/DishType"; +import Spinner from "@/components/Spinner"; +import {fetchDish} from "@/utils/api/dishApi"; +import SyncUsersForm from "@/components/features/dishes/SyncUsersForm"; +import {ChevronLeftIcon} from "@heroicons/react/16/solid"; +import useRoutes from "@/hooks/useRoutes"; +import OutlineLinkButton from "@/components/ui/Buttons/OutlineLinkButton"; +import Hr from "@/components/ui/Hr" + +export default function EditDishPage({ params }: { params: Promise<{ id: number }> }) { + const { id } = use(params) + const [dish, setDish] = useState(null) + const [isLoading, setIsLoading] = useState(true); + const routes = useRoutes(); + + const loadDish = useCallback(async () => { + try { + const fetchedDish = await fetchDish(id); + setDish(fetchedDish); + } catch (error) { + console.error("Error fetching dish:", error); + throw new Error('No token found in localStorage.'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + loadDish(); + }, [loadDish]); + + + if (isLoading || dish === null) { + return + } + + return ( +
+
+ Edit Dish + + +

BACK

+
+
+ + + +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/archive/src/app/dishes/create/page.tsx b/frontend/archive/src/app/dishes/create/page.tsx new file mode 100644 index 0000000..156c038 --- /dev/null +++ b/frontend/archive/src/app/dishes/create/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import CreateDishForm from "@/components/features/dishes/CreateDishForm"; + +export default function CreateDishPage() { + return +} \ No newline at end of file diff --git a/frontend/archive/src/app/dishes/page.tsx b/frontend/archive/src/app/dishes/page.tsx new file mode 100644 index 0000000..274a848 --- /dev/null +++ b/frontend/archive/src/app/dishes/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import PageTitle from "@/components/ui/PageTitle"; +import {DishType} from "@/types/DishType"; +import Dish from "@/components/features/dishes/Dish"; +import {PlusIcon} from "@heroicons/react/24/solid"; +import {useEffect, useState} from "react"; +import useRoutes from "@/hooks/useRoutes"; +import {listDishes} from "@/utils/api/dishApi"; +import Button from "@/components/ui/Button" +export const dynamic = 'force-dynamic'; + +export default function DishesIndexPage() { + const routes = useRoutes(); + + const [dishes, setDishes] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + listDishes() + .then((dishes: DishType[]) => setDishes(dishes)) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

Loading...

; + + if (! dishes) { + return

Loading...

+ } + + return ( + <> +
+
+ Dishes +
+
+ +
+
+ + { + dishes.length === 0 + ?

No dishes found :(

+ : dishes.map((dish: DishType, index: number) => ) + } + + ); +} diff --git a/frontend/archive/src/app/favicon.ico b/frontend/archive/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/archive/src/app/favicon.ico differ diff --git a/frontend/archive/src/app/layout.tsx b/frontend/archive/src/app/layout.tsx new file mode 100644 index 0000000..f6d242b --- /dev/null +++ b/frontend/archive/src/app/layout.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import type { Metadata } from 'next'; +import NavBar from '@/components/layout/NavBar'; +import { AuthProvider } from '@/context/AuthContext'; +import AuthGuard from "@/components/layout/AuthGuard"; +import '@/styles/main.css'; + +export const metadata: Metadata = { + title: 'DishPlanner', + description: 'Schedule your dishes', +}; + +export default function RootLayout({children,}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + Dish Planner + + + + + +
{children}
+
+
+ + + ); +} diff --git a/frontend/archive/src/app/login/page.tsx b/frontend/archive/src/app/login/page.tsx new file mode 100644 index 0000000..cf92a57 --- /dev/null +++ b/frontend/archive/src/app/login/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import LoginForm from "@/components/features/auth/LoginForm"; + +export default function LoginPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/archive/src/app/page.tsx b/frontend/archive/src/app/page.tsx new file mode 100644 index 0000000..8c98217 --- /dev/null +++ b/frontend/archive/src/app/page.tsx @@ -0,0 +1,9 @@ +import UpcomingDishes from "@/components/features/schedule/UpcomingDishes"; +export const dynamic = 'force-dynamic'; + +export default async function FrontPage() { + + return ( + + ); +} diff --git a/frontend/archive/src/app/register/page.tsx b/frontend/archive/src/app/register/page.tsx new file mode 100644 index 0000000..1cad575 --- /dev/null +++ b/frontend/archive/src/app/register/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import RegistrationForm from "@/components/features/auth/RegistrationForm"; + +const RegistrationPage = () => { + return ( +
+ +
+ ); + +} + +export default RegistrationPage \ No newline at end of file diff --git a/frontend/archive/src/app/schedule/[date]/edit/page.tsx b/frontend/archive/src/app/schedule/[date]/edit/page.tsx new file mode 100644 index 0000000..a1d3097 --- /dev/null +++ b/frontend/archive/src/app/schedule/[date]/edit/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { use } from "react"; +import ScheduleEditForm from "@/components/features/schedule/ScheduleEditForm"; + +const ScheduleEditPage = ({ params }: { params: Promise<{ date: string }> }) => { + const { date } = use(params) + + return +} + +export default ScheduleEditPage \ No newline at end of file diff --git a/frontend/archive/src/app/scheduled-user-dishes/history/page.tsx b/frontend/archive/src/app/scheduled-user-dishes/history/page.tsx new file mode 100644 index 0000000..aa6f772 --- /dev/null +++ b/frontend/archive/src/app/scheduled-user-dishes/history/page.tsx @@ -0,0 +1,9 @@ +import HistoricalDishes from "@/components/features/schedule/HistoricalDishes"; +export const dynamic = 'force-dynamic'; + +export default async function HistoryPage() { + + return ( + + ); +} diff --git a/frontend/archive/src/app/users/[id]/edit/page.tsx b/frontend/archive/src/app/users/[id]/edit/page.tsx new file mode 100644 index 0000000..3545ae0 --- /dev/null +++ b/frontend/archive/src/app/users/[id]/edit/page.tsx @@ -0,0 +1,30 @@ +'use client' + +import {FC, use, useEffect, useState} from "react"; +import {UserType} from "@/types/UserType"; +import {showUser} from "@/utils/api/usersApi"; +import Spinner from "@/components/Spinner"; +import EditUserForm from "@/components/features/users/EditUserForm"; +export const dynamic = 'force-dynamic'; + +interface Props { + params: Promise<{ id: number }>; +} + +const UpdateUsersPage: FC = ({ params }) => { + const { id } = use(params) + const [user, setUser] = useState(null) + + useEffect(() => { + showUser(id) + .then((user: UserType) => setUser(user)) + }, [id]); + + if (!user) { + return + } + + return +} + +export default UpdateUsersPage; \ No newline at end of file diff --git a/frontend/archive/src/app/users/create/page.tsx b/frontend/archive/src/app/users/create/page.tsx new file mode 100644 index 0000000..80480f9 --- /dev/null +++ b/frontend/archive/src/app/users/create/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import PageTitle from "@/components/ui/PageTitle"; +import useRoutes from "@/hooks/useRoutes"; +import {useRouter} from "next/navigation"; +import {useState} from "react"; +import Alert from "@/components/ui/Alert"; +import {createUser} from "@/utils/api/usersApi"; +import Link from "next/link"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; +export const dynamic = 'force-dynamic'; + +const CreateUsersPage = () => { + const [name, setName] = useState(''); + const [error, setError] = useState(''); + const router = useRouter(); + const routes = useRoutes(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + setError('Name cannot be empty.'); + return; + } + + createUser(name) + .then(() => { + router.push(routes.user.index()) + }) + } + + return ( +
+ Create User + Back to users + +
+ { + error != '' && { error } + } + + + setName(e.target.value)} + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + + Create +
+
+ ); +} + +export default CreateUsersPage; \ No newline at end of file diff --git a/frontend/archive/src/app/users/page.tsx b/frontend/archive/src/app/users/page.tsx new file mode 100644 index 0000000..0943dd8 --- /dev/null +++ b/frontend/archive/src/app/users/page.tsx @@ -0,0 +1,78 @@ +'use client' + +import PageTitle from "@/components/ui/PageTitle"; +import {useFetchUsers} from "@/hooks/useFetchUsers"; +import Spinner from "@/components/Spinner"; +import useRoutes from "@/hooks/useRoutes"; +import Link from "next/link"; +import {PencilIcon, PlusIcon, TrashIcon} from "@heroicons/react/24/solid"; +import React from "react"; +import {deleteUser} from "@/utils/api/usersApi"; +import {UserType} from "@/types/UserType"; +import Card from "@/components/layout/Card"; +import OutlineLinkButton from "@/components/ui/Buttons/OutlineLinkButton"; + +const UsersPage = () => { + const { users, isLoading } = useFetchUsers(); + const routes = useRoutes(); + + const handleDelete = (user: UserType) => { + deleteUser(user) + .then(() => window.location.reload()) + } + + if (isLoading) { + return ; + } + + const usersList = () => { + return users.map((user) => ( + +
+ {user.name} +
+
+
+ +
+ +
+ +
+
+ handleDelete(user)}> +
+ +
+ +
+
+
+ )) + }; + + return ( +
+
+
+ Users +
+ +
+ + +

Add User

+
+
+
+ + { + users && users.length > 0 + ? usersList() + :
No users
+ } +
+ ); +} + +export default UsersPage; \ No newline at end of file diff --git a/frontend/archive/src/components/Spinner.tsx b/frontend/archive/src/components/Spinner.tsx new file mode 100644 index 0000000..f170616 --- /dev/null +++ b/frontend/archive/src/components/Spinner.tsx @@ -0,0 +1,15 @@ +const Spinner = () => { + + return ( +
+ + + + +
+ ) + +} + +export default Spinner \ No newline at end of file diff --git a/frontend/archive/src/components/features/OnboardingBanner.tsx b/frontend/archive/src/components/features/OnboardingBanner.tsx new file mode 100644 index 0000000..9bbc003 --- /dev/null +++ b/frontend/archive/src/components/features/OnboardingBanner.tsx @@ -0,0 +1,49 @@ +import { FC } from "react" +import Link from "next/link" +import useRoutes from "@/hooks/useRoutes" +import { UserType } from "@/types/UserType" +import { DishType } from "@/types/DishType" + +interface Props { + dishes: DishType[], + users: UserType[] +} + +const OnboardingBanner: FC = ({ dishes, users }) => { + const routes = useRoutes(); + + const steps = [ + { + label: "Create a user", + href: routes.user.create(), + count: users.length + }, { + label: "Create a dish", + href: routes.dish.create(), + count: dishes.length + } + ] + + return ( +
+
Welcome to DishPlanner
+
To get you started, please follow these steps to set up your account. This will ensure a better + experience. +
+ + { + steps.map((step, index) => ( +
+ { + step.count === 0 + ? { step.label } + :
{ step.label }
+ } +
+ )) + } +
+ ) +} + +export default OnboardingBanner; \ No newline at end of file diff --git a/frontend/archive/src/components/features/auth/LoginForm.tsx b/frontend/archive/src/components/features/auth/LoginForm.tsx new file mode 100644 index 0000000..2189c36 --- /dev/null +++ b/frontend/archive/src/components/features/auth/LoginForm.tsx @@ -0,0 +1,96 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { login } from "@/utils/api/auth"; +import { useRouter } from 'next/navigation'; +import Link from "next/link"; +import useRoutes from "@/hooks/useRoutes"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; +import { useSearchParams } from 'next/navigation'; +import Alert from "@/components/ui/Alert"; + +export default function LoginForm() { + const { login: authLogin } = useAuth(); + const router = useRouter(); + const routes = useRoutes(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const searchParams = useSearchParams(); + const [alertSuccess, setAlertSuccess] = useState([]) + + // handle registration success message + const isRegistered = searchParams.get('registered') === 'true'; + + useEffect(() => { + if (isRegistered) { + setAlertSuccess(['Registration successful!',' You can now log in.']); + + const timer = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + params.delete('registered'); + const newUrl = `${window.location.pathname}?${params.toString()}`; + + router.replace(newUrl); + }, 3000); + + return () => clearTimeout(timer) + } + }, [isRegistered, router, searchParams]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + + try { + await login(email, password); + authLogin(); + router.replace('/'); + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : 'Login failed'; + setError(errorMessage); + } + }; + + return ( +
+ { alertSuccess.length > 0 && + + {alertSuccess.map((msg, index) => ( + + {msg} +
+
+ ))} +
+ } +
+ {error &&

{error}

} + setEmail(e.target.value)} + required + className="w-full p-2 mb-4 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + setPassword(e.target.value)} + required + className="w-full p-2 mb-4 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + Login + + Create an account + +
+
+ ); +} diff --git a/frontend/archive/src/components/features/auth/RegistrationForm.tsx b/frontend/archive/src/components/features/auth/RegistrationForm.tsx new file mode 100644 index 0000000..0738381 --- /dev/null +++ b/frontend/archive/src/components/features/auth/RegistrationForm.tsx @@ -0,0 +1,104 @@ +'use client'; + +import React, { useState } from 'react'; +import { register } from "@/utils/api/auth"; +import { useRouter } from 'next/navigation'; +import useRoutes from "@/hooks/useRoutes"; +import Link from "next/link"; +import SectionTitle from "@/components/ui/SectionTitle"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; + +export default function LoginForm() { + const router = useRouter(); + const routes = useRoutes(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordAgain, setPasswordAgain] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRegistered, setIsRegistered] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (password !== passwordAgain) { + setError("Passwords do not match."); + return; + } + + try { + setIsLoading(true); + + await register(name, email, password, passwordAgain); + + router.replace('/login?registered=true'); + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : 'Registration\n failed'; + setError(errorMessage); + } finally { + setIsRegistered(true); + setIsLoading(false); + } + }; + + if (isRegistered) { + return
+ Registration successful! + Please continue to the login page. +
+ } + + return ( +
+
+

Register

+ { error &&

{ error }

} + setName(e.target.value) } + required + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + setEmail(e.target.value) } + required + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + setPassword(e.target.value) } + required + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + setPasswordAgain(e.target.value) } + required + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + + Create Account + + + Back to Login + +
+
+ ); +} diff --git a/frontend/archive/src/components/features/dishes/AddUserToDishForm.tsx b/frontend/archive/src/components/features/dishes/AddUserToDishForm.tsx new file mode 100644 index 0000000..2e2d2c8 --- /dev/null +++ b/frontend/archive/src/components/features/dishes/AddUserToDishForm.tsx @@ -0,0 +1,101 @@ +import React, { FC, useState } from "react"; +import { DishType } from "@/types/DishType"; +import { UserType } from "@/types/UserType"; +import { useFetchUsers } from "@/hooks/useFetchUsers"; +import Spinner from "@/components/Spinner"; +import {addUserToDish} from "@/utils/api/dishApi"; +import OutlineButton from "@/components/ui/Buttons/OutlineButton"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; + +interface Props { + dish: DishType; + reloadDish: () => void; +} + +const AddUserToDishForm: FC = ({ dish, reloadDish }) => { + const [showAdd, setShowAdd] = useState(false); + const [selectedUser, setSelectedUser] = useState("-1"); + const { users, isLoading: isUsersLoading } = useFetchUsers(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedUser === "-1") { + alert("Please select a valid user."); + return; + } + + const userToAdd = users.find((user: UserType) => user.id === parseInt(selectedUser)); + + if (!userToAdd) { + alert("User not found."); + return; + } + + addUserToDish(dish.id, userToAdd.id) + .then(() => { + setShowAdd(false); + setSelectedUser("-1"); + reloadDish(); + }) + .catch(() => { + alert("Failed to add user, please try again."); + }); + }; + + if (isUsersLoading) { + return ; + } + + const remainingUsers = users.filter( + (user: UserType) => + !dish.users.find((dishUser: UserType) => dishUser.id === user.id) + ); + + return ( + <> + setShowAdd(!showAdd)} + disabled={remainingUsers.length === 0} + type="button" + > + Add User + + + { showAdd && ( +
+
+
+
+ +
+ +
+ + Add User + +
+
+
+
+ )} + + ); +}; + +export default AddUserToDishForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/CreateDishForm.tsx b/frontend/archive/src/components/features/dishes/CreateDishForm.tsx new file mode 100644 index 0000000..242c1f2 --- /dev/null +++ b/frontend/archive/src/components/features/dishes/CreateDishForm.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { createDish } from "@/utils/api/dishApi"; +import PageTitle from "@/components/ui/PageTitle"; +import Alert from "@/components/ui/Alert"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; +import OutlineLinkButton from "@/components/ui/Buttons/OutlineLinkButton"; +import { ChevronLeftIcon } from "@heroicons/react/16/solid"; +import Hr from "@/components/ui/Hr" + +const CreateDishForm = () => { + const router = useRouter() + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const validateForm = () => { + if (!name.trim()) { + setError("Dish name cannot be empty."); + return false; + } + + return true; + }; + + const submitForm = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate client-side input + if (!validateForm()) return; + + setError(""); + setLoading(true); + + try { + const result = await createDish(name); + if (result) { + router.push('/dishes') + } + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An unexpected error occurred."); + } finally { + setLoading(false); + } + } + + return ( +
+
+ Create Dish +
+ +
+ { error && ( + { error } + ) } + +
+ + setName(e.target.value) } // Update the name state on change + className="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-secondary focus:bg-gray-900" + placeholder="Enter dish name" + /> +
+ + + { loading ? "Saving..." : "Save Changes" } + +
+ +
+ + } + > + Back to dishes + +
+ ); + + +}; + +export default CreateDishForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/Dish.tsx b/frontend/archive/src/components/features/dishes/Dish.tsx new file mode 100644 index 0000000..7933a41 --- /dev/null +++ b/frontend/archive/src/components/features/dishes/Dish.tsx @@ -0,0 +1,39 @@ +import {DishType} from "@/types/DishType"; + +import {PencilIcon, TrashIcon} from '@heroicons/react/24/solid' +import Link from "next/link"; +import useRoutes from "@/hooks/useRoutes"; +import {UserType} from "@/types/UserType"; +import Card from "@/components/layout/Card"; + +const Dish = ({ dish }: { dish: DishType}) => { + const routes = useRoutes(); + + return ( + +
+

{ dish.name }

+ + { + dish.users.map((user: UserType) => ( +
{user.name.slice(0, 1)}
+ )) + } +
+
+ +
+ +
+ + +
+ +
+ +
+
+ ) +} + +export default Dish \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/DishCard.tsx b/frontend/archive/src/components/features/dishes/DishCard.tsx new file mode 100644 index 0000000..66b2c52 --- /dev/null +++ b/frontend/archive/src/components/features/dishes/DishCard.tsx @@ -0,0 +1,23 @@ +import {UserType} from "@/types/UserType"; +import {FC} from "react"; +import {DishType} from "@/types/DishType"; + +interface Props { + user: UserType, + dish: DishType, +} + +const DishCard: FC = ({ user, dish }: Props) => { + return ( +
+
+ { user.name.slice(0, 1) } +
+
+ { dish ? dish.name : '-' } +
+
+ ) +} + +export default DishCard \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/EditDishForm.tsx b/frontend/archive/src/components/features/dishes/EditDishForm.tsx new file mode 100644 index 0000000..cfffb2a --- /dev/null +++ b/frontend/archive/src/components/features/dishes/EditDishForm.tsx @@ -0,0 +1,87 @@ +import React, {FC, useState} from "react"; +import {useRouter} from "next/navigation"; +import Alert from "@/components/ui/Alert"; +import {updateDish} from "@/utils/api/dishApi"; +import {DishType} from "@/types/DishType"; +import useRoutes from "@/hooks/useRoutes"; +import Spinner from "@/components/Spinner"; +import Button from "@/components/ui/Button" + +interface Props { + dish: DishType +} + +const EditDishForm: FC = ({ dish }) => { + const [name, setName] = useState(dish.name); + const [error, setError] = useState(""); + const router = useRouter() + const [loading, setLoading] = useState(false); + const routes = useRoutes(); + + const validateForm = () => { + if (!name.trim()) { + setError("Dish name cannot be empty."); + return false; + } + + return true; + }; + + const submitForm = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) return; + + setError(""); + setLoading(true); + + try { + const result = await updateDish(dish.id, name); + if (result) { + router.push(routes.dish.index()) + } + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An unexpected error occurred"); + } finally { + setLoading(false); // Reset loading state + } + } + + if (loading) { + return ; + } + + return ( +
+ { + error != '' && { error } + } + + {/* Dish name input */} +
+ + setName(e.target.value)} // Update the name state on change + className="p-2 border rounded w-full bg-gray-500 border-secondary background-secondary" + /> +
+ + {/* Save button */} + +
+ ); +} + +export default EditDishForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/EditDishUserCardEditForm.tsx b/frontend/archive/src/components/features/dishes/EditDishUserCardEditForm.tsx new file mode 100644 index 0000000..eb4bc06 --- /dev/null +++ b/frontend/archive/src/components/features/dishes/EditDishUserCardEditForm.tsx @@ -0,0 +1,119 @@ +import React, {FC} from "react"; +import SectionTitle from "@/components/ui/SectionTitle"; +import {syncUserDishRecurrences} from "@/utils/api/usersApi"; +import Spinner from "@/components/Spinner"; +import {UserDishType} from "@/types/ScheduledUserDishType"; +import {RecurrenceType} from "@/types/ScheduleType"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; + +interface Props { + userDish: UserDishType + onSubmit: () => void +} + +const EditDishUserCardEditForm: FC = ({ userDish, onSubmit}) => { + const weeklyRecurrence = userDish.recurrences.find((recurrence) => recurrence.type === 'App\\Models\\WeeklyRecurrence') + const minimumRecurrence = userDish.recurrences.find((recurrence) => recurrence.type === 'App\\Models\\MinimumRecurrence') + + const wv = weeklyRecurrence ? weeklyRecurrence.value : undefined + const mv = minimumRecurrence ? minimumRecurrence.value : undefined + + const [isWeeklyOn, setIsWeeklyOn] = React.useState(weeklyRecurrence !== undefined); + const [isMinimumOn, setIsMinimumOn] = React.useState(minimumRecurrence !== undefined); + const [weekday, setWeekday] = React.useState(wv ?? 0); + const [minimumValue, setMinimumValue] = React.useState(mv ?? 7); + const [loading, setLoading] = React.useState(false); + + const handleSubmit = () => { + const recurrences = [] + + if (isWeeklyOn) { + recurrences.push({ + type: 'App\\Models\\WeeklyRecurrence', + value: weekday, + }); + } + + if (isMinimumOn) { + recurrences.push({ + type: 'App\\Models\\MinimumRecurrence', + value: minimumValue, + }); + } + + setLoading(true) + syncUserDishRecurrences(userDish.dish.id, userDish.user.id, recurrences as RecurrenceType[]) + .then((data) => console.log('request data', data)) + .finally(() => { + setLoading(false) + onSubmit() + }) + } + + if (loading) { + return ; + } + + return ( +
+ Recurrences + +
+
+ setIsWeeklyOn(!isWeeklyOn)} + className="w-4 h-4 border border-gray-300 rounded-sm bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800" + /> + +
+ { + isWeeklyOn && ( +
+ + +
+ ) + } +
+ +
+
+ setIsMinimumOn(!isMinimumOn)} + className="w-4 h-4 border border-gray-300 rounded-sm bg-gray-500 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800" + /> + +
+ + { + isMinimumOn && ( +
+ setMinimumValue(parseInt(e.currentTarget.value))} min="0" max="365" className="background-secondary border-secondary border-2 w-12 px-2" /> + +
+ ) + } +
+ + Save +
+ ); +} + +export default EditDishUserCardEditForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/RecurrenceLabels.tsx b/frontend/archive/src/components/features/dishes/RecurrenceLabels.tsx new file mode 100644 index 0000000..bba81ba --- /dev/null +++ b/frontend/archive/src/components/features/dishes/RecurrenceLabels.tsx @@ -0,0 +1,54 @@ +import {FC} from "react"; +import {RecurrenceType} from "@/types/ScheduleType"; + +interface Props { + recurrences: RecurrenceType[]; +} + +const RecurrenceLabels: FC = ({recurrences}) => { + const weeklyRecurrences = recurrences.filter(recurrence => recurrence.type === 'App\\Models\\WeeklyRecurrence'); + const minimumRecurrences = recurrences.filter(recurrence => recurrence.type === 'App\\Models\\MinimumRecurrence'); + + const renderWeeklyRecurrence = () => { + if (weeklyRecurrences == undefined || weeklyRecurrences.length == 0) { + return ''; + } + + const weekdayString = (() => { + switch (weeklyRecurrences[0].value) { + case 0: return "Sunday" + case 1: return "Monday" + case 2: return "Tuesday"; + case 3: return "Wednesday"; + case 4: return "Thursday"; + case 5: return "Friday"; + case 6: return "Saturday"; + default: return "Invalid day"; + } + }) + + return ( +
+ { weekdayString() } +
+ ) + } + const renderMinimumRecurrence = () => { + if (minimumRecurrences == undefined || minimumRecurrences.length == 0) { + return ''; + } + + return ( +
+ min: { minimumRecurrences[0].value } +
+ ) + } + + return <> + { renderWeeklyRecurrence() } + { renderMinimumRecurrence() } + ; +}; + +export default RecurrenceLabels; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/SyncUsersForm.tsx b/frontend/archive/src/components/features/dishes/SyncUsersForm.tsx new file mode 100644 index 0000000..296167d --- /dev/null +++ b/frontend/archive/src/components/features/dishes/SyncUsersForm.tsx @@ -0,0 +1,32 @@ +import React, { FC } from "react"; +import { DishType } from "@/types/DishType"; +import { UserType } from "@/types/UserType"; +import UserDishCard from "@/components/features/dishes/UserDishCard"; +import SectionTitle from "@/components/ui/SectionTitle"; +import AddUserToDishForm from "@/components/features/dishes/AddUserToDishForm"; + +interface Props { + dish: DishType; + reloadDish: () => void; +} + +const SyncUsersForm: FC = ({ dish, reloadDish }) => { + return ( +
+ Users + + + + {dish.users.map((user: UserType) => ( + + ))} +
+ ); +}; + +export default SyncUsersForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/dishes/UserDishCard.tsx b/frontend/archive/src/components/features/dishes/UserDishCard.tsx new file mode 100644 index 0000000..4e06e8c --- /dev/null +++ b/frontend/archive/src/components/features/dishes/UserDishCard.tsx @@ -0,0 +1,83 @@ +import React, {FC, useEffect} from "react"; +import {DishType} from "@/types/DishType"; +import {UserType} from "@/types/UserType"; +import Link from "next/link"; +import {PencilIcon, TrashIcon} from "@heroicons/react/24/solid"; +import {removeUserFromDish} from "@/utils/api/dishApi"; +import EditDishUserCardEditForm from "@/components/features/dishes/EditDishUserCardEditForm"; +import {getUserDishForUserAndDish} from "@/utils/api/usersApi"; +import Spinner from "@/components/Spinner"; +import RecurrenceLabels from "@/components/features/dishes/RecurrenceLabels"; +import {UserDishType} from "@/types/ScheduledUserDishType"; + +interface Props { + dish: DishType + user: UserType + reloadDish: () => void +} + +const UserDishCard: FC = ({dish, user, reloadDish}) => { + const [userDish, setUserDish] = React.useState(null); + const [userDishLoading, setUserDishLoading] = React.useState(true); + const [isEditMode, setIsEditMode] = React.useState(false); + + useEffect(() => { + getUserDishForUserAndDish(user.id, dish.id) + .then((userDish) => setUserDish(userDish)) + .finally(() => setUserDishLoading(false)) + }, [dish, user]); + + const handleRemove = () => { + removeUserFromDish(dish.id, user.id) + .then(() => reloadDish()) + .catch(() => { + alert("Failed to remove user, please try again."); + }); + }; + + if (userDishLoading || !userDish) { + return + } + + const onUserCardSubmit = () => { + setIsEditMode(false); + reloadDish() + } + + return ( +
+
+
+ {user.name} +
+ +
+ +
+ +
+ setIsEditMode(!isEditMode)} href="#"> +
+ +
+ +
+
+ +
+ +
+ +
+
+ + {isEditMode && ( +
+ +
+ )} +
+ ); +} + +export default UserDishCard; \ No newline at end of file diff --git a/frontend/archive/src/components/features/navbar/MobileDropdownMenu.tsx b/frontend/archive/src/components/features/navbar/MobileDropdownMenu.tsx new file mode 100644 index 0000000..3e8639a --- /dev/null +++ b/frontend/archive/src/components/features/navbar/MobileDropdownMenu.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import React, {FC} from "react"; +import useRoutes from "@/hooks/useRoutes"; +import classNames from "classnames"; + +interface Props { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + handleLogout: (e: React.MouseEvent) => void; +} + +const divStyles = classNames( + 'absolute', 'text-xxl', 'rounded-b', 'top-full mt-1', 'left-0', 'right-0', '', 'py-2', + 'bg-gray-600', 'border-secondary', 'shadow-md', 'flex', 'flex-col', 'space-y-3', + 'md:hidden' +) + +const linkStyles = classNames( + 'border-b-2', 'border-secondary', 'uppercase', + 'text-primary', 'hover:background-secondary', 'pb-2', 'pl-5', + 'space-grotesk', 'text-xl' +) + +const MobileDropdownMenu: FC = ({ isOpen, setIsOpen, handleLogout }) => { + const routes = useRoutes(); + + if (!isOpen) return null; + + return ( +
+ setIsOpen(false)} + > + Home + + setIsOpen(false)} + > + Dishes + + setIsOpen(false)} + > + Users + + setIsOpen(false)} + > + History + + + Logout + +
+ ) +} + +export default MobileDropdownMenu \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/HistoricalDishes.tsx b/frontend/archive/src/components/features/schedule/HistoricalDishes.tsx new file mode 100644 index 0000000..ad3718f --- /dev/null +++ b/frontend/archive/src/components/features/schedule/HistoricalDishes.tsx @@ -0,0 +1,44 @@ +"use client" + +import {useEffect, useState} from "react"; +import {DateTime} from "luxon"; +import ScheduleCalendar from "@/components/features/schedule/ScheduleCalendar"; +import PageTitle from "@/components/ui/PageTitle"; +import {ScheduleType} from "@/types/ScheduleType"; +import Spinner from "@/components/Spinner"; +import {listSchedule} from "@/utils/api/scheduleApi"; + +const HistoricalDishes = () => { + const [schedule, setSchedule] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const yesterday = DateTime.now().minus({ days: 1 }).toFormat('yyyy-LL-dd'); + + useEffect(() => { + listSchedule(undefined, yesterday) + .then((dishes: ScheduleType[]) => dishes + .sort((a: ScheduleType, b: ScheduleType) => new Date(b.date).getTime() - new Date(a.date).getTime()) + ) + .then((dishes) => setSchedule(dishes)) + .finally(() => setIsLoading(false)) + }, [yesterday]); + + if (isLoading) { + return ; + } + + if (!schedule || Object.keys(schedule).length === 0) { + return ( +
+ No dishes scheduled +
+ ); + } + + return
+ History + +
+} + +export default HistoricalDishes \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/ScheduleCalendar.tsx b/frontend/archive/src/components/features/schedule/ScheduleCalendar.tsx new file mode 100644 index 0000000..b146f6a --- /dev/null +++ b/frontend/archive/src/components/features/schedule/ScheduleCalendar.tsx @@ -0,0 +1,75 @@ +"use client" + +import {FC} from "react"; +import ScheduleDayCard from "@/components/features/schedule/dayCard/ScheduleDayCard"; +import { FilledScheduleType, ScheduleType } from "@/types/ScheduleType"; +import {useFetchUsers} from "@/hooks/useFetchUsers"; +import Spinner from "@/components/Spinner"; + +const generateDates = (startDate: string, days: number): string[] => { + const dates = []; + const start = new Date(startDate); + + for (let i = 0; i < days; i++) { + const currentDate = new Date(start); + currentDate.setDate(start.getDate() + i); + dates.push(currentDate.toISOString().split('T')[0]); + } + + return dates; +}; + + +const fillCalendar = (schedules: ScheduleType[]): FilledScheduleType[] => { + /* +Array(14) + 0: + date: "2025-05-05" + id: 2 + is_skipped: false + scheduled_user_dishes: [] + */ + + const dates = generateDates((new Date()).toISOString().split('T')[0], 31) + + return dates.map((date): FilledScheduleType => { + console.log(date) + + const schedule = schedules.find((schedule: ScheduleType) => schedule.date == date) + + if (schedule) { + return schedule + } + + return { + date, + scheduled_user_dishes: [] + } + }) +} + +interface Props { + schedule: ScheduleType[]; +} + +const ScheduleCalendar: FC = ({ schedule }: Props) => { + const {users, isLoading: areUsersLoading} = useFetchUsers(); + + if (areUsersLoading) return + + const fullCalendar = fillCalendar(schedule) + + return ( +
+ { fullCalendar.map((schedule, index) => ( + + ))} +
+ ) +} + +export default ScheduleCalendar \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/ScheduleEditForm.tsx b/frontend/archive/src/components/features/schedule/ScheduleEditForm.tsx new file mode 100644 index 0000000..0f436a9 --- /dev/null +++ b/frontend/archive/src/components/features/schedule/ScheduleEditForm.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React, { FC, useEffect, useState } from "react"; +import { ScheduleType } from "@/types/ScheduleType"; +import Spinner from "@/components/Spinner"; +import PageTitle from "@/components/ui/PageTitle"; +import { getScheduleForDate, scheduleUserDish, updateScheduleForDate } from "@/utils/api/scheduleApi"; +import { UserDishType } from "@/types/ScheduledUserDishType"; +import Label from "@/components/ui/Label"; +import SectionTitle from "@/components/ui/SectionTitle"; +import { useFetchUsers } from "@/hooks/useFetchUsers"; +import { listUserDishes } from "@/utils/api/userDishApi"; +import scheduleBuilder from "@/utils/scheduleBuilder"; +import transformDate from "@/utils/dateBuilder"; +import { ChevronLeftIcon } from "@heroicons/react/16/solid"; +import Hr from "@/components/ui/Hr" +import Button from "@/components/ui/Button" + +interface Props { + date: string; +} + +const ScheduleEditForm: FC = ({ date }) => { + const [schedule, setSchedule] = useState() + const [userDishes, setUserDishes] = useState([]) + const [isScheduleLoading, setIsScheduleLoading] = useState(true); + const [areUserDishesLoading, setAreUserDishesLoading] = useState(true); + const { users } = useFetchUsers(); + + useEffect(() => { + getScheduleForDate(date) + .then((sched: ScheduleType) => setSchedule(sched)) + .finally(() => setIsScheduleLoading(false)) + }, [date]); + + + useEffect(() => { + listUserDishes() + .then((user_dishes: UserDishType[]) => setUserDishes(user_dishes)) + .finally(() => setAreUserDishesLoading(false)) + }, []); + + const handleSkipDay = () => { + updateScheduleForDate(date, true) + .then((schedule: ScheduleType) => { + setSchedule(schedule) + }) + } + + const handleUnskipDay = () => { + updateScheduleForDate(date, false) + .then((schedule: ScheduleType) => { + setSchedule(schedule) + }) + } + + const handleChange = (e: React.ChangeEvent, userId: number) => { + const userDishId = parseInt(e.currentTarget.value); + + if (userDishId === 0) { + scheduleUserDish(date, userId, null, true).then(() => window.location.reload()); + return; + } + + scheduleUserDish(date, userId, userDishId).then(() => window.location.reload()); + } + + if (isScheduleLoading || areUserDishesLoading || !schedule) { + return + } + + const scheduleData = scheduleBuilder(schedule, users, userDishes) + + return
+
+
+ Edit Day +
+
+ { transformDate(schedule.date) } +
+
+ +
+ + { + userDishes.length === 0 &&
+
No dishes found assigned to this user.
+
Go ahead and add some first, or choose to skip the day.
+
(dishes ={`>`} edit ={`>`} add user)
+
+ } + + { schedule.is_skipped + ? + : ( + <> + { + scheduleData + .map((scheduleData) =>
+
{ scheduleData.user.name }
+
+ +
+
) + } + + ) + } + +
Changes are saved automatically
+ +
+ +
+ +
{ + schedule.is_skipped + ? + : + }
+
+
+} + +export default ScheduleEditForm \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/ScheduleRegenerateButton.tsx b/frontend/archive/src/components/features/schedule/ScheduleRegenerateButton.tsx new file mode 100644 index 0000000..5b60c2b --- /dev/null +++ b/frontend/archive/src/components/features/schedule/ScheduleRegenerateButton.tsx @@ -0,0 +1,35 @@ +import {FC, useState} from "react"; +import Modal from "@/components/ui/Modal"; +import ScheduleRegenerateForm from "@/components/features/schedule/ScheduleRegenerateForm"; +import {ArrowPathIcon} from "@heroicons/react/16/solid"; + +interface ScheduleRegenerateButtonProps { + onModalClose?: () => void; +} + +const ScheduleRegenerateButton: FC = ({ onModalClose }) => { + const [open, setOpen] = useState(false); + + const handleCloseModal = () => { + setOpen(false) + if (onModalClose) { + onModalClose() + } + } + + const modalChildren = handleCloseModal()}/> + const buttonChild =
+
+ + return +}; + +export default ScheduleRegenerateButton; \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/ScheduleRegenerateForm.tsx b/frontend/archive/src/components/features/schedule/ScheduleRegenerateForm.tsx new file mode 100644 index 0000000..017b8c2 --- /dev/null +++ b/frontend/archive/src/components/features/schedule/ScheduleRegenerateForm.tsx @@ -0,0 +1,78 @@ +import {DialogTitle} from "@headlessui/react"; +import Toggle from "@/components/ui/Toggle"; +import {FC, useEffect, useState} from "react"; +import {generateSchedule} from "@/utils/api/scheduleApi"; +import Alert from "@/components/ui/Alert"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; + +interface ScheduleRegenerateFormProps { + closeModal: () => void; +} + +const ScheduleRegenerateForm: FC = ({closeModal}) => { + const [overwrite, setOverwrite] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + }, [overwrite]); + + const close = () => { + closeModal(); + } + + const handleToggle = () => { + setOverwrite(!overwrite) + } + + const handleSubmit = () => { + generateSchedule(overwrite) + .then(() => close()) + .catch((err) => setError(err)) + } + + return <> +
+
+
+ + Regenerate Schedule + +
+
+ { + error && { error } + } +
+ +
+
+ +
+
+
+ + +
+
+
+
+ handleSubmit()} + className="inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-xs sm:ml-3 sm:w-auto" + > + Regenerate + + close()} + className="mt-3 inline-flex w-full justify-center rounded-md bg-gray-500 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 shadow-xs border-secondary ring-inset sm:mt-0 sm:w-auto" + > + Cancel + +
+ ; +}; + +export default ScheduleRegenerateForm; \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/UpcomingDishes.tsx b/frontend/archive/src/components/features/schedule/UpcomingDishes.tsx new file mode 100644 index 0000000..c7731d0 --- /dev/null +++ b/frontend/archive/src/components/features/schedule/UpcomingDishes.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useCallback, useEffect, useState } from "react"; +import { DateTime } from "luxon"; +import ScheduleCalendar from "@/components/features/schedule/ScheduleCalendar"; +import PageTitle from "@/components/ui/PageTitle"; +import Spinner from "@/components/Spinner"; +import { ScheduleType } from "@/types/ScheduleType"; +import { listSchedule } from "@/utils/api/scheduleApi"; +import OnboardingBanner from "@/components/features/OnboardingBanner" +import { useFetchUsers } from "@/hooks/useFetchUsers" +import { useFetchDishes } from "@/hooks/useFetchDishes" +import ScheduleRegenerateButton from "@/components/features/schedule/ScheduleRegenerateButton"; + +const UpcomingDishes = () => { + const [schedule, setSchedule] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const today = DateTime.now().toFormat("yyyy-LL-dd"); + + const fetchSchedule = useCallback(() => { + setIsLoading(true); + listSchedule(today) + .then((dishes) => setSchedule(dishes)) + .finally(() => setIsLoading(false)); + }, [today]); + + useEffect(() => { + fetchSchedule(); + }, [fetchSchedule]); + + const { users, isLoading: areUsersLoading } = useFetchUsers(); + const { dishes, isLoading: areDishesLoading } = useFetchDishes(); + + if (isLoading || areUsersLoading || areDishesLoading) { + return ; + } + + if (users.length === 0 || dishes.length === 0) { + return + } + + return ( +
+
+
+ Schedule +
+
+ +
+
+ { + !schedule || Object.keys(schedule).length === 0 + ?
No dishes scheduled
+ : + } +
+ ); +}; + +export default UpcomingDishes; \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/UserDishEditCard.tsx b/frontend/archive/src/components/features/schedule/UserDishEditCard.tsx new file mode 100644 index 0000000..b82fc1b --- /dev/null +++ b/frontend/archive/src/components/features/schedule/UserDishEditCard.tsx @@ -0,0 +1,79 @@ +import {FC, FormEvent, useMemo, useState} from "react"; +import {DishType} from "@/types/DishType"; +import {ScheduledUserDishType} from "@/types/ScheduledUserDishType"; +import {updateScheduledUserDish} from "@/utils/api/scheduledUserDishesApi"; +import Alert from "@/components/ui/Alert"; +import classNames from "classnames"; + +interface Props { + scheduledUserDish: ScheduledUserDishType + allDishes: DishType[] +} + +const UserDishEditCard: FC = ({ scheduledUserDish, allDishes }) => { + const [selectedUserDishId, setSelectedUserDishId] = useState(scheduledUserDish.user_dish ? scheduledUserDish.user_dish.id : 0) + const [errorMessage, setErrorMessage] = useState("") + const [isSuccess, setIsSuccess] = useState(false); + + const selectStyle = classNames( + 'p-2', 'rounded', 'w-full', 'background-secondary', + 'focus:outline-none', + 'transition-[border-color] ease-out duration-1000', 'border-2', // Keep consistent base styles + { + 'border-green-500': isSuccess, // Green border when successful + 'border-red-500': !isSuccess && errorMessage !== "", // Red border when there's an error + 'border-secondary': !isSuccess && errorMessage === "", // Default border for neutral state + } + ) + + const handleOnChange = (e: FormEvent) => { + const userDishId = parseInt(e.currentTarget.value); + setSelectedUserDishId(userDishId); + + updateScheduledUserDish(scheduledUserDish.id, userDishId) + .then(() => { + setIsSuccess(false); + setTimeout(() => { + setIsSuccess(true); + setTimeout(() => setIsSuccess(false), 1000); + }, 0); + }) + .catch((error) => { + setErrorMessage(error); // Log API errors + }); + }; + + const filteredDishes = useMemo(() => + allDishes.filter((dish: DishType) => + dish.users.some((user) => user.id === scheduledUserDish.user_dish.user.id) + ), + [allDishes, scheduledUserDish.user_dish.user.id] + ) + + return ( +
+
{scheduledUserDish.user_dish.user.name}
+ + { errorMessage !== "" && { errorMessage } } + + + +
+ ); +}; + +export default UserDishEditCard; \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/dayCard/DateBadge.tsx b/frontend/archive/src/components/features/schedule/dayCard/DateBadge.tsx new file mode 100644 index 0000000..9a61d87 --- /dev/null +++ b/frontend/archive/src/components/features/schedule/dayCard/DateBadge.tsx @@ -0,0 +1,27 @@ +import {DateTime} from "luxon"; +import React, {FC} from "react"; +import classNames from "classnames"; + +interface Props { + date: string + className?: string; +} + +const DateBadge: FC = ({ className, date }) => { + const isToday = DateTime.fromISO(date).toFormat("yyyy-LL-dd") == DateTime.now().toFormat("yyyy-LL-dd") + + const textStyle = classNames("inline font-bold", { + 'text-accent-blue': isToday, + 'text-secondary': !isToday, + }, className) + + return ( +
+
{DateTime.fromISO(date).toFormat("dd")}
+
+
{DateTime.fromISO(date).toFormat("LLL")}
+
+ ) +} + +export default DateBadge \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCard.tsx b/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCard.tsx new file mode 100644 index 0000000..77c68e0 --- /dev/null +++ b/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCard.tsx @@ -0,0 +1,50 @@ +import React, {FC} from "react"; +import {UserType} from "@/types/UserType"; +import ScheduleDayCardUserDish from "@/components/features/schedule/dayCard/ScheduleDayCardUserDish"; +import { FilledScheduleType, ScheduleType } from "@/types/ScheduleType"; +import Link from "next/link"; +import {PencilSquareIcon} from "@heroicons/react/24/outline"; +import useRoutes from "@/hooks/useRoutes"; +import DateBadge from "@/components/features/schedule/dayCard/DateBadge"; +import { DateTime } from "luxon" +import classNames from "classnames" + +interface Props { + schedule: ScheduleType|FilledScheduleType; + users: UserType[]; +} + +const ScheduleDayCard: FC = ({schedule, users}) => { + const routes = useRoutes() + const isToday = DateTime.fromISO(schedule.date).toFormat("yyyy-LL-dd") == DateTime.now().toFormat("yyyy-LL-dd") + + const containerStyles = classNames( + 'w-full bg-gray-500 pt-5 pb-2 rounded-2xl text-xl', { + 'border-2 text-accent-blue border-accent-blue': isToday, + } + ) + + return ( +
+ + +
+ { + users.map((user) => ) + } + +
+ + Edit + +
+
+
+ ); +}; + +export default ScheduleDayCard; \ No newline at end of file diff --git a/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCardUserDish.tsx b/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCardUserDish.tsx new file mode 100644 index 0000000..64f0f0e --- /dev/null +++ b/frontend/archive/src/components/features/schedule/dayCard/ScheduleDayCardUserDish.tsx @@ -0,0 +1,32 @@ +import React, { FC } from "react"; +import { ScheduledUserDishType } from "@/types/ScheduledUserDishType"; +import { UserType } from "@/types/UserType"; +import { FilledScheduleType, ScheduleType } from "@/types/ScheduleType"; + +interface Props { + schedule: ScheduleType|FilledScheduleType; + user: UserType; +} + +const ScheduleDayCardUserDish: FC = ({ schedule, user }) => { + const getDish = (user: UserType) => { + const scheduled_dishes = schedule.scheduled_user_dishes.filter((scheduled_user_dish: ScheduledUserDishType) => ( + scheduled_user_dish.user_dish?.user.id == user.id + )) + + if (scheduled_dishes.length > 0) { + return scheduled_dishes[0].user_dish.dish.name + } + + return '/' + } + + return ( +
+
{ user.name } :
+
{ getDish(user) }
+
+ ); +}; + +export default ScheduleDayCardUserDish; \ No newline at end of file diff --git a/frontend/archive/src/components/features/users/EditUserForm.tsx b/frontend/archive/src/components/features/users/EditUserForm.tsx new file mode 100644 index 0000000..1593dd9 --- /dev/null +++ b/frontend/archive/src/components/features/users/EditUserForm.tsx @@ -0,0 +1,63 @@ +import React, {FC, useState} from "react"; +import {useRouter} from "next/navigation"; +import useRoutes from "@/hooks/useRoutes"; +import {updateUser} from "@/utils/api/usersApi"; +import PageTitle from "@/components/ui/PageTitle"; +import Link from "next/link"; +import Alert from "@/components/ui/Alert"; +import {UserType} from "@/types/UserType"; +import SolidButton from "@/components/ui/Buttons/SolidButton"; + +interface Props { + user: UserType; +} + +const EditUserForm: FC = ({ user }) => { + + const [name, setName] = useState(user.name); + const [error, setError] = useState(''); + const router = useRouter(); + const routes = useRoutes(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // validateName + if (!name.trim()) { + setError('Name cannot be empty.'); + return; + } + + updateUser(user, name) + .then(() => { + router.push(routes.user.index()) + }) + } + + return ( +
+ Create User + Back to users + +
+ { + error != '' && { error } + } + + + setName(e.target.value)} + className="w-full p-2 border rounded bg-primary border-secondary bg-gray-600 text-secondary" + /> + + Update +
+
+ ); +} + +export default EditUserForm; \ No newline at end of file diff --git a/frontend/archive/src/components/layout/AuthGuard.tsx b/frontend/archive/src/components/layout/AuthGuard.tsx new file mode 100644 index 0000000..c4e0482 --- /dev/null +++ b/frontend/archive/src/components/layout/AuthGuard.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useAuth } from '@/context/AuthContext'; +import { useRouter, usePathname } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; + +// Optional Loading spinner component to display while loading +const LoadingSpinner = () => ( +
+
+
+); + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth(); // Access the authentication state from AuthContext + const router = useRouter(); + const pathname = usePathname(); + const [loading, setLoading] = useState(true); + + // Define public routes that can be accessed without authentication + const publicRoutes = ['/login', '/register']; + const isPublic = publicRoutes.includes(pathname); + + useEffect(() => { + // Determine behavior based on auth state and route type + if (isAuthenticated === null) { + // Await authentication resolution (e.g., token check) + setLoading(true); + } else if (isAuthenticated && isPublic) { + // Redirect authenticated users away from public pages + router.replace('/'); + } else if (!isAuthenticated && !isPublic) { + // Redirect unauthenticated users trying to access protected pages + router.replace('/login'); + } else { + // Otherwise, stop loading since the state is resolved + setLoading(false); + } + }, [isAuthenticated, pathname, isPublic, router]); + + // Show a spinner while authentication state is loading + if (loading) { + return ; + } + + // Render children only when the authentication state and path are valid + return <>{children}; +} \ No newline at end of file diff --git a/frontend/archive/src/components/layout/Card.tsx b/frontend/archive/src/components/layout/Card.tsx new file mode 100644 index 0000000..6784526 --- /dev/null +++ b/frontend/archive/src/components/layout/Card.tsx @@ -0,0 +1,15 @@ +import React, {FC} from "react"; + +interface Props { + children: React.ReactNode; +} + +const Card: FC = ({ children }) => { + return ( +
+ { children } +
+ ) +} + +export default Card \ No newline at end of file diff --git a/frontend/archive/src/components/layout/NavBar.tsx b/frontend/archive/src/components/layout/NavBar.tsx new file mode 100644 index 0000000..67cadb2 --- /dev/null +++ b/frontend/archive/src/components/layout/NavBar.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import useRoutes from "@/hooks/useRoutes"; +import MobileDropdownMenu from "@/components/features/navbar/MobileDropdownMenu"; +import {useRouter} from "next/navigation"; +import {useAuth} from "@/context/AuthContext"; + +const NavBar = () => { + const [isOpen, setIsOpen] = useState(false); + const routes = useRoutes(); + const router = useRouter(); + const {isAuthenticated, logout} = useAuth(); + + const handleLogout = (e: React.MouseEvent) => { + e.preventDefault(); + logout(); + router.replace('/login'); + }; + + return ( + + ); +}; + +export default NavBar; \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Alert.tsx b/frontend/archive/src/components/ui/Alert.tsx new file mode 100644 index 0000000..c32070c --- /dev/null +++ b/frontend/archive/src/components/ui/Alert.tsx @@ -0,0 +1,34 @@ +import React, {FC} from "react"; +import classNames from "classnames"; + +interface Props { + children: React.ReactNode; + className?: string; + type: 'error' | 'warning' | 'info' | 'success'; +} + +const Alert: FC = ({ children, className, type } ) => { + let bgColor = 'bg-blue-200' + let fgColor = 'bg-blue-800' + + if (type == 'error') { + bgColor = 'bg-red-200' + fgColor = 'bg-red-800' + } else if (type == 'warning') { + bgColor = 'bg-orange-200' + fgColor = 'bg-orange-800' + } else if (type == 'success') { + bgColor = 'border-2 border-green-500' + fgColor = 'text-green-500' + } + + const styles = classNames(fgColor, bgColor, className, 'rounded') + + return ( +
+ { children} +
+ ) +} + +export default Alert \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Button.tsx b/frontend/archive/src/components/ui/Button.tsx new file mode 100644 index 0000000..4ad006c --- /dev/null +++ b/frontend/archive/src/components/ui/Button.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import React, { FC, ReactElement, ReactNode } from "react"; +import classNames from "classnames"; + +interface ButtonProps { + appearance?: 'solid' | 'outline' | 'text'; + children: ReactNode; + className?: string; + href?: string; + icon?: ReactNode; + onClick?: () => void; + disabled?: boolean; + size?: 'small' | 'medium' | 'large'; + type?: 'button' | 'submit' | 'reset'; + variant?: 'primary' | 'secondary' | 'accent'; +} + +const Button: FC = ({ appearance, children, className, disabled, href, icon, onClick, + size = 'medium', type, + variant = 'primary' +}) => { + const styles = classNames( + "flex items-center space-x-1", + "justify-center font-size-18 py-2 px-4 rounded flex", + { + 'border-2 border-primary background-red text-white': variant === 'primary' && appearance === 'solid', + 'border-2 border-primary text-primary': variant === 'primary' && appearance === 'outline', + 'text-primary': variant === 'primary' && appearance === 'text', + 'border-2 border-secondary text-secondary': variant === 'secondary' && appearance === 'outline', + 'border-2 border-accent-blue text-accent-blue': variant === 'accent' && appearance === 'outline', + }, + className + ) + + const iconClassNames = classNames({ + "h-4 w-4 mr-1": size === "small", + "h-5 w-5 mr-1": size === "medium", + "h-7 w-7 mr-2": size === "large", + }); + + const iconElement = + React.isValidElement(icon) && + React.cloneElement(icon as ReactElement<{ className?: string }>, { + className: iconClassNames, + }); + + if (href !== undefined) { + return ( + + { icon && iconElement} + { children} + + ) + } + + return +} + +export default Button \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Buttons/OutlineButton.tsx b/frontend/archive/src/components/ui/Buttons/OutlineButton.tsx new file mode 100644 index 0000000..b668b6b --- /dev/null +++ b/frontend/archive/src/components/ui/Buttons/OutlineButton.tsx @@ -0,0 +1,37 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +interface Props { + children: React.ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void; + size?: "small" | "medium" | "large"; + type: 'submit' | 'button'; +} + +const OutlineButton: FC = ({ children, className, disabled = false, onClick, size, type }) => { + const style = classNames( + "justify-center border-2 border-accent font-size-18 text-accent-blue py-2 px-4 rounded flex", + { 'text-xs': size === "small" }, + className + ) + + if (onClick === undefined) { + onClick = () => { + } + } + + return ( + + ) +} + +export default OutlineButton \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Buttons/OutlineLinkButton.tsx b/frontend/archive/src/components/ui/Buttons/OutlineLinkButton.tsx new file mode 100644 index 0000000..ab0e9b1 --- /dev/null +++ b/frontend/archive/src/components/ui/Buttons/OutlineLinkButton.tsx @@ -0,0 +1,49 @@ +import React, { FC, ReactElement } from "react"; +import classNames from "classnames"; +import Link from "next/link"; + +interface Props { + children: React.ReactNode; + className?: string; + href: string; + icon?: React.ReactNode; + size?: "small" | "medium" | "large"; + variant?: "primary" | "secondary"; +} + +const OutlineLinkButton: FC = ({ children, className, href, icon, size = "medium", variant }) => { + const linkClassNames = classNames( + "underline font-default pt-3 pb-3 px-4 rounded mb-0 flex", + { + 'text-primary border-primary': variant === "primary", + 'text-secondary border-secondary': variant === "secondary", + 'text-accent-blue border-accent': !variant || !["primary", "secondary"].includes(variant), + }, { + 'text-size-14': size === "small", + 'font-size-18': !size || size === "medium", + 'text-2xl': size === "large", + }, + className, + ) + + const iconClassNames = classNames("mt-0.5", { + "h-4 w-4 mr-1": size === "small", + "h-5 w-5 mr-1": size === "medium", // Default size + "h-7 w-7 mr-2": size === "large", + }); + + const iconElement = + React.isValidElement(icon) && + React.cloneElement(icon as ReactElement<{ className?: string }>, { + className: iconClassNames, + }); + + return ( + + {iconElement} + {children} + + ) +} + +export default OutlineLinkButton \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Buttons/SolidButton.tsx b/frontend/archive/src/components/ui/Buttons/SolidButton.tsx new file mode 100644 index 0000000..62ac01d --- /dev/null +++ b/frontend/archive/src/components/ui/Buttons/SolidButton.tsx @@ -0,0 +1,39 @@ +import React, {FC} from "react"; +import classNames from "classnames"; + +interface Props { + children: React.ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void; + size?: "small" | "medium" | "large"; + type: 'submit' | 'button'; +} + +const SolidButton: FC = ({ children, className, disabled = false, onClick, size, type }) => { + const style = classNames( + "py-2 px-4 bg-primary text-white text-xl p-2 rounded hover:bg-secondary mb-0", + { + 'text-xs' : size === "small", + 'font-size-18' : !size || size === "medium", + }, + className + ) + + if (onClick === undefined) { + onClick = () => {} + } + + return ( + + ) +} + +export default SolidButton \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Buttons/SolidLinkButton.tsx b/frontend/archive/src/components/ui/Buttons/SolidLinkButton.tsx new file mode 100644 index 0000000..77a78dd --- /dev/null +++ b/frontend/archive/src/components/ui/Buttons/SolidLinkButton.tsx @@ -0,0 +1,46 @@ +import React, { FC, ReactElement } from "react"; +import classNames from "classnames"; +import Link from "next/link"; + +interface Props { + children: React.ReactNode; + className?: string; + href: string; + icon?: React.ReactNode; + size?: "small" | "medium" | "large"; + variant?: "primary" | "secondary"; +} + +const SolidLinkButton: FC = ({ children, className, href, icon, size = "medium", variant }) => { + const style = classNames( + "py-2 px-4 text-xl p-2 rounded hover:bg-secondary mb-0 text-center flex", + { + 'background-red text-white': variant === "primary", + 'background-secondary border-2 border-secondary': variant === "secondary", + }, + className + ) + + const iconClassNames = classNames("mt-1", { + "h-4 w-4 mr-1": size === "small", + "h-5 w-5 mr-1": size === "medium", // Default size + "h-7 w-7 mr-2": size === "large", + }); + + const iconElement = + React.isValidElement(icon) && + React.cloneElement(icon as ReactElement<{ className?: string }>, { + className: iconClassNames, + }); + + return ( + +
+ {iconElement} + {children} +
+ + ) +} + +export default SolidLinkButton \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Description.tsx b/frontend/archive/src/components/ui/Description.tsx new file mode 100644 index 0000000..8b429c0 --- /dev/null +++ b/frontend/archive/src/components/ui/Description.tsx @@ -0,0 +1,17 @@ +import classNames from "classnames"; +import React from "react"; + +interface Props { + children: React.ReactNode; + className?: string; +} + +const Description = ({ children, className }: Props) => { + const style = classNames("italic font-size-16", + className + ) + + return

{ children }

+} + +export default Description \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Hr.tsx b/frontend/archive/src/components/ui/Hr.tsx new file mode 100644 index 0000000..59f0d2d --- /dev/null +++ b/frontend/archive/src/components/ui/Hr.tsx @@ -0,0 +1,14 @@ +import { FC } from "react" +import classNames from "classnames" + +interface HrProps { + className?: string; +} + +const Hr: FC = ({ className }) => { + const styles = classNames("my-4 border-secondary", className) + + return
+} + +export default Hr \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Label.tsx b/frontend/archive/src/components/ui/Label.tsx new file mode 100644 index 0000000..80bea0b --- /dev/null +++ b/frontend/archive/src/components/ui/Label.tsx @@ -0,0 +1,25 @@ +import React, {FC, ReactNode} from "react"; + +interface LabelProps { + href?: string; + children: ReactNode; + onClick?: () => void; +} + +const Label: FC = ({ href, children, onClick }) => { + const styles = "items-center space-x-1 background-accent p-2 rounded" + + if (href !== undefined) { + return ( +
+ { children} +
+ ) + } + + return +} + +export default Label \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Modal.tsx b/frontend/archive/src/components/ui/Modal.tsx new file mode 100644 index 0000000..e6e5c9c --- /dev/null +++ b/frontend/archive/src/components/ui/Modal.tsx @@ -0,0 +1,62 @@ +import {FC, JSX} from "react"; +import {Dialog, DialogBackdrop, DialogPanel} from "@headlessui/react"; +import classNames from "classnames"; +import {XMarkIcon} from "@heroicons/react/24/outline"; +import Button from "@/components/ui/Button" + +interface ModalProps { + buttonChildren?: JSX.Element; + buttonClassName?: string; + buttonLabel?: string; + modalChildren: JSX.Element; + modalOpen?: boolean; + setModalOpen: (open: boolean) => void; +} + +const Modal: FC = ({ + buttonLabel, + buttonClassName, + modalChildren, + modalOpen, + buttonChildren, + setModalOpen, +}) => { + const buttonStyles = classNames(buttonClassName, 'anta-regular'); + + const closeModal = () => { + setModalOpen(false) + } + + return ( + <> + + + + +
+
+ + closeModal()}/> + {modalChildren} + +
+
+
+ + ) +} + +export default Modal; \ No newline at end of file diff --git a/frontend/archive/src/components/ui/PageTitle.tsx b/frontend/archive/src/components/ui/PageTitle.tsx new file mode 100644 index 0000000..ca65f18 --- /dev/null +++ b/frontend/archive/src/components/ui/PageTitle.tsx @@ -0,0 +1,18 @@ +import classNames from "classnames"; +import {FC} from "react"; + +interface Props { + children: string, + className?: string, +} + +const PageTitle: FC = ({ children, className }) => { + const styles = classNames( + 'ml-4 text-2xl font-default uppercase w-full text-accent-blue font-bold', + className, + ) + + return

{ children }

+} + +export default PageTitle \ No newline at end of file diff --git a/frontend/archive/src/components/ui/RecurrenceInput.tsx b/frontend/archive/src/components/ui/RecurrenceInput.tsx new file mode 100644 index 0000000..756715d --- /dev/null +++ b/frontend/archive/src/components/ui/RecurrenceInput.tsx @@ -0,0 +1,65 @@ +import React, {FC, useState} from "react"; + +interface Props { + value: number; + setValue: (value: number) => void; +} + +const RecurrenceInput: FC = ({ value, setValue}) => { + const [openInput, setOpenInput] = useState<'category' | 'number'>([7, 365].includes(value) ? 'category' : 'number') + + const toggleInput = (e: React.MouseEvent) => { + e.preventDefault() + setOpenInput(openInput == 'category' ? 'number' : 'category') + } + + const toggleButton = () => { + return ( + + ) + } + + const prepareValue = (v: string) => { + setValue(parseInt(v)) + } + + return ( +
+
+ + + { toggleButton() } +
+ +
+ + prepareValue(e.target.value)} + className="p-2 border rounded w-full bg-gray-500 border-secondary" + /> + { toggleButton() } +
+
+ ) +} + +export default RecurrenceInput \ No newline at end of file diff --git a/frontend/archive/src/components/ui/SectionTitle.tsx b/frontend/archive/src/components/ui/SectionTitle.tsx new file mode 100644 index 0000000..93d13c4 --- /dev/null +++ b/frontend/archive/src/components/ui/SectionTitle.tsx @@ -0,0 +1,16 @@ +import classNames from "classnames"; + +interface Props { + children: string; + className?: string; +} + +const SectionTitle = ({ children, className }: Props) => { + const style = classNames("block font-size-18 uppercase w-full pl-2 text-accent-blue", + className + ) + + return

{ children }

+} + +export default SectionTitle \ No newline at end of file diff --git a/frontend/archive/src/components/ui/Toggle.tsx b/frontend/archive/src/components/ui/Toggle.tsx new file mode 100644 index 0000000..786b093 --- /dev/null +++ b/frontend/archive/src/components/ui/Toggle.tsx @@ -0,0 +1,42 @@ +import {FC} from "react"; + +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; +} + +const Toggle: FC = ({ checked, onChange }) => { + const handleChange = () => { + onChange(checked); + } + + return ( + + ); +}; + +export default Toggle; \ No newline at end of file diff --git a/frontend/archive/src/context/AuthContext.tsx b/frontend/archive/src/context/AuthContext.tsx new file mode 100644 index 0000000..8d77eb3 --- /dev/null +++ b/frontend/archive/src/context/AuthContext.tsx @@ -0,0 +1,46 @@ +"use client" + +import React, { createContext, useContext, useEffect, useState } from 'react'; + +interface AuthContextProps { + isAuthenticated: boolean | null; + login: () => void; + logout: () => void; +} + +const AuthContext = createContext({ + isAuthenticated: null, + login: () => {}, + logout: () => {}, +}); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) { + // You could add any token validation logic here + setIsAuthenticated(true); + } else { + setIsAuthenticated(false); + } + }, []); + + const login = () => { + setIsAuthenticated(true); + }; + + const logout = () => { + setIsAuthenticated(false); + localStorage.removeItem('token'); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/frontend/archive/src/helpers/Date.ts b/frontend/archive/src/helpers/Date.ts new file mode 100644 index 0000000..f183c86 --- /dev/null +++ b/frontend/archive/src/helpers/Date.ts @@ -0,0 +1,18 @@ +import { DateTime } from 'luxon'; + +// Validate if a given string matches the "yyyy-MM-dd" format and is a valid date +export const isValidDate = (date: string): boolean => { + const parsedDate = DateTime.fromFormat(date, 'yyyy-MM-dd'); + return parsedDate.isValid && parsedDate.toFormat('yyyy-MM-dd') === date; +}; + +// Format a date to a specific string format +export const formatDate = (date: Date | string, format: string = 'yyyy-MM-dd'): string => { + const parsedDate = typeof date === 'string' ? DateTime.fromISO(date) : DateTime.fromJSDate(date); + return parsedDate.toFormat(format); +}; + +// Compare two dates to see if one is before the other +export const isBefore = (date1: string, date2: string): boolean => { + return DateTime.fromISO(date1) < DateTime.fromISO(date2); +}; diff --git a/frontend/archive/src/hooks/useFetchDishes.ts b/frontend/archive/src/hooks/useFetchDishes.ts new file mode 100644 index 0000000..a7ab747 --- /dev/null +++ b/frontend/archive/src/hooks/useFetchDishes.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from "react"; +import { listDishes } from "@/utils/api/dishApi" +import { DishType } from "@/types/DishType" + +export const useFetchDishes = () => { + const [dishes, setDishes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDishes = async () => { + listDishes() + .then((dishes: DishType[]) => setDishes(dishes)) + .catch((err) => setError((err as Error).message || "An error occurred.")) + .finally(() => setIsLoading(false)); + }; + + fetchDishes(); + }, []); + + return { dishes, isLoading, error }; +}; \ No newline at end of file diff --git a/frontend/archive/src/hooks/useFetchUsers.ts b/frontend/archive/src/hooks/useFetchUsers.ts new file mode 100644 index 0000000..f6df05b --- /dev/null +++ b/frontend/archive/src/hooks/useFetchUsers.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from "react"; +import {UserType} from "@/types/UserType"; +import {listUsers} from "@/utils/api/usersApi"; + +export const useFetchUsers = () => { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUsers = async () => { + listUsers() + .then((users: UserType[]) => setUsers(users)) + .catch((err) => setError((err as Error).message || "An error occurred.")) + .finally(() => setIsLoading(false)); + }; + + fetchUsers(); + }, []); + + return { users, isLoading, error }; +}; \ No newline at end of file diff --git a/frontend/archive/src/hooks/useRoutes.ts b/frontend/archive/src/hooks/useRoutes.ts new file mode 100644 index 0000000..7120da4 --- /dev/null +++ b/frontend/archive/src/hooks/useRoutes.ts @@ -0,0 +1,32 @@ +import {DishType} from "@/types/DishType"; +import {UserType} from "@/types/UserType"; + +const useRoutes = () => { + return { + home: () => "/", + auth: { + login: () => "/login", + register: () => "/register", + }, + dish: { + index: () => "/dishes", + create: () => "/dishes/create", + edit: (dish: DishType) => `/dishes/${dish.id}/edit`, + delete: (dish: DishType) => `/dishes/${dish.id}/delete`, + }, + schedule: { + date: { + edit: (date: string) => `/schedule/${date}/edit` + }, + history: () => "/scheduled-user-dishes/history", + }, + user: { + index: () => "/users", + create: () => `/users/create`, + edit: (user: UserType) => `/users/${user.id}/edit`, + delete: (user: UserType) => `/users/${user.id}/delete`, + } + }; +}; + +export default useRoutes; \ No newline at end of file diff --git a/frontend/archive/src/styles/base/globals.css b/frontend/archive/src/styles/base/globals.css new file mode 100644 index 0000000..918cbfb --- /dev/null +++ b/frontend/archive/src/styles/base/globals.css @@ -0,0 +1,19 @@ +html, body { + margin: 0; + padding: 0; + width: 100%; + overflow-x: hidden; +} + +body { + font-family: Arial, Helvetica, sans-serif; +} + + +.toggle-input:checked { + background-color: #22c55e; /* bg-green-500 */ +} + +.toggle-input:checked ~ span:last-child { + --tw-translate-x: 1.75rem; /* translate-x-7 */ +} \ No newline at end of file diff --git a/frontend/archive/src/styles/components/buttons.css b/frontend/archive/src/styles/components/buttons.css new file mode 100644 index 0000000..07fc4de --- /dev/null +++ b/frontend/archive/src/styles/components/buttons.css @@ -0,0 +1,42 @@ +.button-primary-solid { + background-color: var(--color-primary); + color: var(--color-secondary-200); + border: 1px solid var(--color-primary); + text-transform: uppercase; + font-family: "Anta", serif; + font-style: normal; + font-size: 1.1rem; + font-weight: 600; + padding: 4px 16px 2px 16px; +} +.button-primary-outline { + background-color: var(--color-background); + color: var(--color-primary); + border: 1px solid var(--color-primary); + text-transform: uppercase; + font-family: "Anta", serif; + font-style: normal; + font-size: 1.1rem; + font-weight: 600; + padding: 4px 16px 2px 16px; +} + +.button-secondary-solid { + background-color: var(--color-secondary); + color: var(--color-primary); + border: 1px solid var(--color-secondary); +} + +.button-accent-solid { + background-color: var(--color-accent-blue); + color: var(--color-secondary-900); + border: 1px solid var(--color-accent-blue); +} +.button-accent-outline { + background-color: var(--color-background); + color: var(--color-accent-blue); + border: 1px solid var(--color-accent-blue); +} +.button-accent-outline:hover { + background-color: var(--color-background-400); +} \ No newline at end of file diff --git a/frontend/archive/src/styles/components/select.css b/frontend/archive/src/styles/components/select.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/archive/src/styles/main.css b/frontend/archive/src/styles/main.css new file mode 100644 index 0000000..5ca69ea --- /dev/null +++ b/frontend/archive/src/styles/main.css @@ -0,0 +1,10 @@ +@import "./theme/borders.css"; +@import "./theme/fonts.css"; +@import "./components/buttons.css"; + +@import "./base/globals.css"; +@import "./theme/colors.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/archive/src/styles/theme/borders.css b/frontend/archive/src/styles/theme/borders.css new file mode 100644 index 0000000..b4ed5db --- /dev/null +++ b/frontend/archive/src/styles/theme/borders.css @@ -0,0 +1,14 @@ +.border-primary { + border-color: var(--color-primary); +} + +.border-secondary { + border-color: var(--color-secondary); +} + +.border-accent-blue { + border-color: var(--color-accent-blue); +} +.border-accent-800 { + border-color: var(--color-accent-blue-800); +} \ No newline at end of file diff --git a/frontend/archive/src/styles/theme/colors.css b/frontend/archive/src/styles/theme/colors.css new file mode 100644 index 0000000..338bcd7 --- /dev/null +++ b/frontend/archive/src/styles/theme/colors.css @@ -0,0 +1,10 @@ +@import './colors/root.css'; +@import 'colors/background.css'; +@import 'colors/border.css'; +@import 'colors/text.css'; + +body { + color: var(--color-secondary) !important; + background: var(--color-gray-600) !important; +} + diff --git a/frontend/archive/src/styles/theme/colors/background.css b/frontend/archive/src/styles/theme/colors/background.css new file mode 100644 index 0000000..e8548f5 --- /dev/null +++ b/frontend/archive/src/styles/theme/colors/background.css @@ -0,0 +1,226 @@ +.bg-gray-100 { + background-color: var(--color-gray-100) !important; +} +.bg-gray-200 { + background-color: var(--color-gray-200) !important; +} +.bg-gray-300 { + background-color: var(--color-gray-300) !important; +} +.bg-gray-400 { + background-color: var(--color-gray-400) !important; +} +.bg-gray-500 { + background-color: var(--color-gray-500) !important; +} +.bg-gray-600 { + background-color: var(--color-gray-600) !important; +} +.bg-gray-700 { + background-color: var(--color-gray-700) !important; +} +.bg-gray-800 { + background-color: var(--color-gray-800) !important; +} +.bg-gray-900 { + background-color: var(--color-gray-900) !important; +} + + +.bg-primary { + background-color: var(--color-primary) !important; +} + + +.bg-accent-blue { + background-color: var(--color-accent-blue-500) !important; +} +.bg-accent-blue-100 { + background-color: var(--color-accent-blue-100) !important; +} +.bg-accent-blue-200 { + background-color: var(--color-accent-blue-200) !important; +} +.bg-accent-blue-300 { + background-color: var(--color-accent-blue-300) !important; +} +.bg-accent-blue-400 { + background-color: var(--color-accent-blue-400) !important; +} +.bg-accent-blue-500 { + background-color: var(--color-accent-blue-500) !important; +} +.bg-accent-blue-600 { + background-color: var(--color-accent-blue-600) !important; +} +.bg-accent-blue-700 { + background-color: var(--color-accent-blue-700) !important; +} +.bg-accent-blue-800 { + background-color: var(--color-accent-blue-800) !important; +} +.bg-accent-blue-900 { + background-color: var(--color-accent-blue-900) !important; +} + + +.bg-accent-yellow { + background-color: var(--color-accent-yellow) !important; +} + +.bg-accent-yellow-100 { + background-color: var(--color-accent-yellow-100) !important; +} + +.bg-accent-yellow-200 { + background-color: var(--color-accent-yellow-200) !important; +} + +.bg-accent-yellow-300 { + background-color: var(--color-accent-yellow-300) !important; +} + +.bg-accent-yellow-400 { + background-color: var(--color-accent-yellow-400) !important; +} + +.bg-accent-yellow-500 { + background-color: var(--color-accent-yellow-500) !important; +} + +.bg-accent-yellow-600 { + background-color: var(--color-accent-yellow-600) !important; +} + +.bg-accent-yellow-700 { + background-color: var(--color-accent-yellow-700) !important; +} + +.bg-accent-yellow-800 { + background-color: var(--color-accent-yellow-800) !important; +} + +.bg-accent-yellow-900 { + background-color: var(--color-accent-yellow-900) !important; +} + + +.bg-success { + background-color: var(--color-success) !important; +} + +.bg-success-100 { + background-color: var(--color-success-100) !important; +} + +.bg-success-200 { + background-color: var(--color-success-200) !important; +} + +.bg-success-300 { + background-color: var(--color-success-300) !important; +} + +.bg-success-400 { + background-color: var(--color-success-400) !important; +} + +.bg-success-500 { + background-color: var(--color-success-500) !important; +} + +.bg-success-600 { + background-color: var(--color-success-600) !important; +} + +.bg-success-700 { + background-color: var(--color-success-700) !important; +} + +.bg-success-800 { + background-color: var(--color-success-800) !important; +} + +.bg-success-900 { + background-color: var(--color-success-900) !important; +} + +.bg-warning { + background-color: var(--color-warning) !important; +} + +.bg-warning-100 { + background-color: var(--color-warning-100) !important; +} + +.bg-warning-200 { + background-color: var(--color-warning-200) !important; +} + +.bg-warning-300 { + background-color: var(--color-warning-300) !important; +} + +.bg-warning-400 { + background-color: var(--color-warning-400) !important; +} + +.bg-warning-500 { + background-color: var(--color-warning-500) !important; +} + +.bg-warning-600 { + background-color: var(--color-warning-600) !important; +} + +.bg-warning-700 { + background-color: var(--color-warning-700) !important; +} + +.bg-warning-800 { + background-color: var(--color-warning-800) !important; +} + +.bg-warning-900 { + background-color: var(--color-warning-900) !important; +} + +.bg-danger { + background-color: var(--color-danger) !important; +} + +.bg-danger-100 { + background-color: var(--color-danger-100) !important; +} + +.bg-danger-200 { + background-color: var(--color-danger-200) !important; +} + +.bg-danger-300 { + background-color: var(--color-danger-300) !important; +} + +.bg-danger-400 { + background-color: var(--color-danger-400) !important; +} + +.bg-danger-500 { + background-color: var(--color-danger-500) !important; +} + +.bg-danger-600 { + background-color: var(--color-danger-600) !important; +} + +.bg-danger-700 { + background-color: var(--color-danger-700) !important; +} + +.bg-danger-800 { + background-color: var(--color-danger-800) !important; +} + +.bg-danger-900 { + background-color: var(--color-danger-900) !important; +} \ No newline at end of file diff --git a/frontend/archive/src/styles/theme/colors/border.css b/frontend/archive/src/styles/theme/colors/border.css new file mode 100644 index 0000000..8975296 --- /dev/null +++ b/frontend/archive/src/styles/theme/colors/border.css @@ -0,0 +1,286 @@ +.border-primary { + border-color: var(--color-primary); +} + +.border-primary-100 { + border-color: var(--color-primary-100); +} + +.border-primary-200 { + border-color: var(--color-primary-200); +} + +.border-primary-300 { + border-color: var(--color-primary-300); +} + +.border-primary-400 { + border-color: var(--color-primary-400); +} + +.border-primary-500 { + border-color: var(--color-primary-500); +} + +.border-primary-600 { + border-color: var(--color-primary-600); +} + +.border-primary-700 { + border-color: var(--color-primary-700); +} + +.border-primary-800 { + border-color: var(--color-primary-800); +} + +.border-primary-900 { + border-color: var(--color-primary-900); +} + + +.border-secondary { + border-color: var(--color-secondary); +} + +.border-secondary-100 { + border-color: var(--color-secondary-100); +} + +.border-secondary-200 { + border-color: var(--color-secondary-200); +} + +.border-secondary-300 { + border-color: var(--color-secondary-300); +} + +.border-secondary-400 { + border-color: var(--color-secondary-400); +} + +.border-secondary-500 { + border-color: var(--color-secondary-500); +} + +.border-secondary-600 { + border-color: var(--color-secondary-600); +} + +.border-secondary-700 { + border-color: var(--color-secondary-700); +} + +.border-secondary-800 { + border-color: var(--color-secondary-800); +} + +.border-secondary-900 { + border-color: var(--color-secondary-900); +} + +.border-accent-blue { + border-color: var(--color-accent-blue); +} + +.border-accent-blue-100 { + border-color: var(--color-accent-blue-100); +} + +.border-accent-blue-200 { + border-color: var(--color-accent-blue-200); +} + +.border-accent-blue-300 { + border-color: var(--color-accent-blue-300); +} + +.border-accent-blue-400 { + border-color: var(--color-accent-blue-400); +} + +.border-accent-blue-500 { + border-color: var(--color-accent-blue-500); +} + +.border-accent-blue-600 { + border-color: var(--color-accent-blue-600); +} + +.border-accent-blue-700 { + border-color: var(--color-accent-blue-700); +} + +.border-accent-blue-800 { + border-color: var(--color-accent-blue-800); +} + +.border-accent-blue-900 { + border-color: var(--color-accent-blue-900); +} + + +.border-accent-yellow { + border-color: var(--color-accent-yellow); +} + +.border-accent-yellow-100 { + border-color: var(--color-accent-yellow-100); +} + +.border-accent-yellow-200 { + border-color: var(--color-accent-yellow-200); +} + +.border-accent-yellow-300 { + border-color: var(--color-accent-yellow-300); +} + +.border-accent-yellow-400 { + border-color: var(--color-accent-yellow-400); +} + +.border-accent-yellow-500 { + border-color: var(--color-accent-yellow-500); +} + +.border-accent-yellow-600 { + border-color: var(--color-accent-yellow-600); +} + +.border-accent-yellow-700 { + border-color: var(--color-accent-yellow-700); +} + +.border-accent-yellow-800 { + border-color: var(--color-accent-yellow-800); +} + +.border-accent-yellow-900 { + border-color: var(--color-accent-yellow-900); +} + + +.border-background { + border-color: var(--color-background) !important; +} + +.border-danger { + border-color: var(--color-danger); +} + +.border-danger-100 { + border-color: var(--color-danger-100); +} + +.border-danger-200 { + border-color: var(--color-danger-200); +} + +.border-danger-300 { + border-color: var(--color-danger-300); +} + +.border-danger-400 { + border-color: var(--color-danger-400); +} + +.border-danger-500 { + border-color: var(--color-danger-500); +} + +.border-danger-600 { + border-color: var(--color-danger-600); +} + +.border-danger-700 { + border-color: var(--color-danger-700); +} + +.border-danger-800 { + border-color: var(--color-danger-800); +} + +.border-danger-900 { + border-color: var(--color-danger-900); +} + +.border-success { + border-color: var(--color-success); +} + +.border-success-100 { + border-color: var(--color-success-100); +} + +.border-success-200 { + border-color: var(--color-success-200); +} + +.border-success-300 { + border-color: var(--color-success-300); +} + +.border-success-400 { + border-color: var(--color-success-400); +} + +.border-success-500 { + border-color: var(--color-success-500); +} + +.border-success-600 { + border-color: var(--color-success-600); +} + +.border-success-700 { + border-color: var(--color-success-700); +} + +.border-success-800 { + border-color: var(--color-success-800); +} + +.border-success-900 { + border-color: var(--color-success-900); +} + +.border-warning { + border-color: var(--color-warning); +} + +.border-warning-100 { + border-color: var(--color-warning-100); +} + +.border-warning-200 { + border-color: var(--color-warning-200); +} + +.border-warning-300 { + border-color: var(--color-warning-300); +} + +.border-warning-400 { + border-color: var(--color-warning-400); +} + +.border-warning-500 { + border-color: var(--color-warning-500); +} + +.border-warning-600 { + border-color: var(--color-warning-600); +} + +.border-warning-700 { + border-color: var(--color-warning-700); +} + +.border-warning-800 { + border-color: var(--color-warning-800); +} + +.border-warning-900 { + border-color: var(--color-warning-900); +} \ No newline at end of file diff --git a/frontend/archive/src/styles/theme/colors/root.css b/frontend/archive/src/styles/theme/colors/root.css new file mode 100644 index 0000000..acffac0 --- /dev/null +++ b/frontend/archive/src/styles/theme/colors/root.css @@ -0,0 +1,193 @@ +:root { + --color-rose-50: #FFF5FC; + --color-rose-100: #FCE6F5; + --color-rose-200: #FAC3E7; + --color-rose-300: #F7A1D5; + --color-rose-400: #F25EAB; + --color-rose-500: #ED1F79; + --color-rose-600: #D61A68; + --color-rose-700: #B3124F; + --color-rose-800: #8F0B39; + --color-rose-900: #6B0626; + --color-rose-950: #450315; + + --color-deluge-50: #FAF7FC; + --color-deluge-100: #F2EDF7; + --color-deluge-200: #E2DAF0; + --color-deluge-300: #CEC3E6; + --color-deluge-400: #A49BD1; + --color-deluge-500: #7776BC; + --color-deluge-600: #6361AB; + --color-deluge-700: #43428C; + --color-deluge-800: #2C2B70; + --color-deluge-900: #191854; + --color-deluge-950: #0A0A36; + + --color-malibu-50: #FAFEFF; + --color-malibu-100: #F5FDFF; + --color-malibu-200: #E1F6FC; + --color-malibu-300: #CDEDFA; + --color-malibu-400: #ABDEF7; + --color-malibu-500: #85C7F2; + --color-malibu-600: #6EACDB; + --color-malibu-700: #4A81B5; + --color-malibu-800: #305F91; + --color-malibu-900: #1B3F6E; + --color-malibu-950: #0B2247; + + --color-gamboge-50: #FFFDF2; + --color-gamboge-100: #FCF7E3; + --color-gamboge-200: #FAECBB; + --color-gamboge-300: #F5DC93; + --color-gamboge-400: #EDBB47; + --color-gamboge-500: #E59500; + --color-gamboge-600: #CF7F00; + --color-gamboge-700: #AB6100; + --color-gamboge-800: #8A4700; + --color-gamboge-900: #663000; + --color-gamboge-950: #421C00; + + --color-ebony-clay-100: #9AA2B3; /* Soft slate */ + --color-ebony-clay-200: #7A8093; /* Balanced midtone */ + --color-ebony-clay-300: #5D637A; /* Former 400 */ + --color-ebony-clay-400: #444760; /* New shadowed steel */ + --color-ebony-clay-500: #2B2C41; + --color-ebony-clay-600: #24263C; /* Adjusted — less jumpy */ + --color-ebony-clay-700: #1D1E36; /* Interpolated midpoint */ + --color-ebony-clay-800: #131427; /* Slightly lifted from old 800 */ + --color-ebony-clay-900: #0A0B1C; + --color-ebony-clay-950: #030412; + + --color-alizarin-crimson-50: #FFF5FA; + --color-alizarin-crimson-100: #FCE6F1; + --color-alizarin-crimson-200: #FAC3DC; + --color-alizarin-crimson-300: #F59FC0; + --color-alizarin-crimson-400: #F05D82; + --color-alizarin-crimson-500: #E71D36; + --color-alizarin-crimson-600: #D1192F; + --color-alizarin-crimson-700: #AD1121; + --color-alizarin-crimson-800: #8C0B18; + --color-alizarin-crimson-900: #69060E; + --color-alizarin-crimson-950: #420308; + + --color-spring-green-50: #F5FFFC; + --color-spring-green-100: #E8FFF9; + --color-spring-green-200: #C7FFEE; + --color-spring-green-300: #A4FCDF; + --color-spring-green-400: #62FCBC; + --color-spring-green-500: #21FA90; + --color-spring-green-600: #1BE07A; + --color-spring-green-700: #13BA5E; + --color-spring-green-800: #0C9646; + --color-spring-green-900: #07702D; + --color-spring-green-950: #03471A; + + --color-burning-orange-50: #FFFBF5; + --color-burning-orange-100: #FFF7EB; + --color-burning-orange-200: #FFE8CC; + --color-burning-orange-300: #FFD5AD; + --color-burning-orange-400: #FFA973; + --color-burning-orange-500: #FF6B35; + --color-burning-orange-600: #E65A2C; + --color-burning-orange-700: #BF441F; + --color-burning-orange-800: #993114; + --color-burning-orange-900: #731F0A; + --color-burning-orange-950: #4A1004; + + /* Standard naming */ + + --color-primary: var(--color-rose-500); + --color-primary-100: var(--color-rose-100); + --color-primary-200: var(--color-rose-200); + --color-primary-300: var(--color-rose-300); + --color-primary-400: var(--color-rose-400); + --color-primary-500: var(--color-rose-500); + --color-primary-600: var(--color-rose-600); + --color-primary-700: var(--color-rose-700); + --color-primary-800: var(--color-rose-800); + --color-primary-900: var(--color-rose-900); + + --color-secondary: var(--color-deluge-500); + --color-secondary-100: var(--color-deluge-100); + --color-secondary-200: var(--color-deluge-200); + --color-secondary-300: var(--color-deluge-300); + --color-secondary-400: var(--color-deluge-400); + --color-secondary-500: var(--color-deluge-500); + --color-secondary-600: var(--color-deluge-600); + --color-secondary-700: var(--color-deluge-700); + --color-secondary-800: var(--color-deluge-800); + --color-secondary-900: var(--color-deluge-900); + + --color-accent-blue: var(--color-malibu-500); + --color-accent-blue-100: var(--color-malibu-100); + --color-accent-blue-200: var(--color-malibu-200); + --color-accent-blue-300: var(--color-malibu-300); + --color-accent-blue-400: var(--color-malibu-400); + --color-accent-blue-500: var(--color-malibu-500); + --color-accent-blue-600: var(--color-malibu-600); + --color-accent-blue-700: var(--color-malibu-700); + --color-accent-blue-800: var(--color-malibu-800); + --color-accent-blue-900: var(--color-malibu-900); + + --color-accent-yellow: var(--color-gamboge-500); + --color-accent-yellow-50: var(--color-gamboge-50); + --color-accent-yellow-100: var(--color-gamboge-100); + --color-accent-yellow-200: var(--color-gamboge-200); + --color-accent-yellow-300: var(--color-gamboge-300); + --color-accent-yellow-400: var(--color-gamboge-400); + --color-accent-yellow-500: var(--color-gamboge-500); + --color-accent-yellow-600: var(--color-gamboge-600); + --color-accent-yellow-700: var(--color-gamboge-700); + --color-accent-yellow-800: var(--color-gamboge-800); + --color-accent-yellow-900: var(--color-gamboge-900); + --color-accent-yellow-950: var(--color-gamboge-950); + + --color-gray-100: var(--color-ebony-clay-100); + --color-gray-200: var(--color-ebony-clay-200); + --color-gray-300: var(--color-ebony-clay-300); + --color-gray-400: var(--color-ebony-clay-400); + --color-gray-500: var(--color-ebony-clay-500); + --color-gray-600: var(--color-ebony-clay-600); + --color-gray-700: var(--color-ebony-clay-700); + --color-gray-800: var(--color-ebony-clay-800); + --color-gray-900: var(--color-ebony-clay-900); + + --color-danger: var(--color-alizarin-crimson-500); + --color-danger-50: var(--color-alizarin-crimson-50); + --color-danger-100: var(--color-alizarin-crimson-100); + --color-danger-200: var(--color-alizarin-crimson-200); + --color-danger-300: var(--color-alizarin-crimson-300); + --color-danger-400: var(--color-alizarin-crimson-400); + --color-danger-500: var(--color-alizarin-crimson-500); + --color-danger-600: var(--color-alizarin-crimson-600); + --color-danger-700: var(--color-alizarin-crimson-700); + --color-danger-800: var(--color-alizarin-crimson-800); + --color-danger-900: var(--color-alizarin-crimson-900); + --color-danger-950: var(--color-alizarin-crimson-950); + + --color-success: var(--color-spring-green-500); + --color-success-50: var(--color-spring-green-50); + --color-success-100: var(--color-spring-green-100); + --color-success-200: var(--color-spring-green-200); + --color-success-300: var(--color-spring-green-300); + --color-success-400: var(--color-spring-green-400); + --color-success-500: var(--color-spring-green-500); + --color-success-600: var(--color-spring-green-600); + --color-success-700: var(--color-spring-green-700); + --color-success-800: var(--color-spring-green-800); + --color-success-900: var(--color-spring-green-900); + --color-success-950: var(--color-spring-green-950); + + --color-warning: var(--color-burning-orange-500); + --color-warning-50: var(--color-burning-orange-50); + --color-warning-100: var(--color-burning-orange-100); + --color-warning-200: var(--color-burning-orange-200); + --color-warning-300: var(--color-burning-orange-300); + --color-warning-400: var(--color-burning-orange-400); + --color-warning-500: var(--color-burning-orange-500); + --color-warning-600: var(--color-burning-orange-600); + --color-warning-700: var(--color-burning-orange-700); + --color-warning-800: var(--color-burning-orange-800); + --color-warning-900: var(--color-burning-orange-900); + --color-warning-950: var(--color-burning-orange-950); +} diff --git a/frontend/archive/src/styles/theme/colors/text.css b/frontend/archive/src/styles/theme/colors/text.css new file mode 100644 index 0000000..1683846 --- /dev/null +++ b/frontend/archive/src/styles/theme/colors/text.css @@ -0,0 +1,216 @@ +.text-primary { + color: var(--color-primary); +} +.text-primary-100 { + color: var(--color-primary-100); +} +.text-primary-200 { + color: var(--color-primary-200); +} +.text-primary-300 { + color: var(--color-primary-300); +} +.text-primary-400 { + color: var(--color-primary-400); +} +.text-primary-500 { + color: var(--color-primary-500); +} +.text-primary-600 { + color: var(--color-primary-600); +} +.text-primary-700 { + color: var(--color-primary-700); +} +.text-primary-800 { + color: var(--color-primary-800); +} +.text-primary-900 { + color: var(--color-primary-900); +} + +.text-secondary { + color: var(--color-secondary); +} +.text-secondary-100 { + color: var(--color-secondary-100); +} +.text-secondary-200 { + color: var(--color-secondary-200); +} +.text-secondary-300 { + color: var(--color-secondary-300); +} +.text-secondary-400 { + color: var(--color-secondary-400); +} +.text-secondary-500 { + color: var(--color-secondary-500); +} +.text-secondary-600 { + color: var(--color-secondary-600); +} +.text-secondary-700 { + color: var(--color-secondary-700); +} +.text-secondary-800 { + color: var(--color-secondary-800); +} +.text-secondary-900 { + color: var(--color-secondary-900); +} + +.text-accent-blue { + color: var(--color-accent-blue); +} +.text-accent-blue-100 { + color: var(--color-accent-blue-100); +} +.text-accent-blue-200 { + color: var(--color-accent-blue-200); +} +.text-accent-blue-300 { + color: var(--color-accent-blue-300); +} +.text-accent-blue-400 { + color: var(--color-accent-blue-400); +} +.text-accent-blue-500 { + color: var(--color-accent-blue-500); +} +.text-accent-blue-600 { + color: var(--color-accent-blue-600); +} +.text-accent-blue-700 { + color: var(--color-accent-blue-700); +} +.text-accent-blue-800 { + color: var(--color-accent-blue-800); +} +.text-accent-blue-900 { + color: var(--color-accent-blue-900); +} + +.text-accent-yellow { + color: var(--color-accent-yellow); +} +.text-accent-yellow-100 { + color: var(--color-accent-yellow-100); +} +.text-accent-yellow-200 { + color: var(--color-accent-yellow-200); +} +.text-accent-yellow-300 { + color: var(--color-accent-yellow-300); +} +.text-accent-yellow-400 { + color: var(--color-accent-yellow-400); +} +.text-accent-yellow-500 { + color: var(--color-accent-yellow-500); +} +.text-accent-yellow-600 { + color: var(--color-accent-yellow-600); +} +.text-accent-yellow-700 { + color: var(--color-accent-yellow-700); +} +.text-accent-yellow-800 { + color: var(--color-accent-yellow-800); +} +.text-accent-yellow-900 { + color: var(--color-accent-yellow-900); +} + +.text-danger { + color: var(--color-danger); +} +.text-danger-100 { + color: var(--color-danger-100); +} +.text-danger-200 { + color: var(--color-danger-200); +} +.text-danger-300 { + color: var(--color-danger-300); +} +.text-danger-400 { + color: var(--color-danger-400); +} +.text-danger-500 { + color: var(--color-danger-500); +} +.text-danger-600 { + color: var(--color-danger-600); +} +.text-danger-700 { + color: var(--color-danger-700); +} +.text-danger-800 { + color: var(--color-danger-800); +} +.text-danger-900 { + color: var(--color-danger-900); +} + +.text-warning { + color: var(--color-warning); +} +.text-warning-100 { + color: var(--color-warning-100); +} +.text-warning-200 { + color: var(--color-warning-200); +} +.text-warning-300 { + color: var(--color-warning-300); +} +.text-warning-400 { + color: var(--color-warning-400); +} +.text-warning-500 { + color: var(--color-warning-500); +} +.text-warning-600 { + color: var(--color-warning-600); +} +.text-warning-700 { + color: var(--color-warning-700); +} +.text-warning-800 { + color: var(--color-warning-800); +} +.text-warning-900 { + color: var(--color-warning-900); +} + +.text-success { + color: var(--color-success); +} +.text-success-100 { + color: var(--color-success-100); +} +.text-success-200 { + color: var(--color-success-200); +} +.text-success-300 { + color: var(--color-success-300); +} +.text-success-400 { + color: var(--color-success-400); +} +.text-success-500 { + color: var(--color-success-500); +} +.text-success-600 { + color: var(--color-success-600); +} +.text-success-700 { + color: var(--color-success-700); +} +.text-success-800 { + color: var(--color-success-800); +} +.text-success-900 { + color: var(--color-success-900); +} \ No newline at end of file diff --git a/frontend/archive/src/styles/theme/fonts.css b/frontend/archive/src/styles/theme/fonts.css new file mode 100644 index 0000000..2646418 --- /dev/null +++ b/frontend/archive/src/styles/theme/fonts.css @@ -0,0 +1,95 @@ +/* Import Space Grotesk from Google Fonts */ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&family=Syncopate:wght@400;700&display=swap'); + +/* Global font settings */ + +/* Set Space Grotesk as the default font */ +body { + font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + line-height: 1.6; + color: #333; +} + +/* Use Anta for headings */ +h1, h2, h3 { + font-family: 'Syncopate', sans-serif; + color: #111; +} + +/* Use Space Grotesk for smaller text like paragraphs */ +p { + font-family: 'Space Grotesk', sans-serif; +} + + +.font-default { + font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; +} + +.font-syncopate { + font-family: "Syncopate", serif !important; +} + +.font-space-grotesk { + font-family: 'Space Grotesk', sans-serif; +} + +.font-weight-100 { + font-weight: 100; +} +.font-weight-200 { + font-weight: 200; +} +.font-weight-300 { + font-weight: 300; +} +.font-weight-400 { + font-weight: 400; +} +.font-weight-500 { + font-weight: 500; +} +.font-weight-600 { + font-weight: 600; +} +.font-weight-700 { + font-weight: 700; +} +.font-weight-800 { + font-weight: 800; +} +.font-weight-900 { + font-weight: 900; +} + +.font-size-12 { + font-size: 12px !important; +} + +.font-size-14 { + font-size: 14px !important; +} + +.font-size-16 { + font-size: 16px !important; +} + +.font-size-18 { + font-size: 18px !important; +} + +.font-size-20 { + font-size: 20px !important; +} + +.font-size-24 { + font-size: 24px !important; +} + +.font-size-32 { + font-size: 32px !important; +} +.font-size-48 { + font-size: 48px !important; +} \ No newline at end of file diff --git a/frontend/archive/src/types/DishType.ts b/frontend/archive/src/types/DishType.ts new file mode 100644 index 0000000..e9986c2 --- /dev/null +++ b/frontend/archive/src/types/DishType.ts @@ -0,0 +1,20 @@ +import {UserType} from "@/types/UserType"; + +export type DishType = { + id: number + name: string, + recurrence: number, + users: UserType[], +} + +export type DishDateType = { + id: number; + date: string; + dish: DishType; + user: UserType; +} + +export type ScheduledDishesType = { + date: string; + dishes: { dish: DishType, user: UserType }[]; +} diff --git a/frontend/archive/src/types/ScheduleType.ts b/frontend/archive/src/types/ScheduleType.ts new file mode 100644 index 0000000..9b03fcf --- /dev/null +++ b/frontend/archive/src/types/ScheduleType.ts @@ -0,0 +1,27 @@ +import { ScheduledUserDishType, UserDishType } from "@/types/ScheduledUserDishType"; +import { UserType } from "@/types/UserType"; + +export type RecurrenceType = { + type: "App\\Models\\WeeklyRecurrence" | "App\\Models\\MinimumRecurrence"; + value: number; +} + +export type ScheduleType = { + id: number; + date: string; + scheduled_user_dishes: ScheduledUserDishType[]; + is_skipped: boolean; +} + +export type FilledScheduleType = { + id?: number; + date: string; + is_skipped?: boolean; + scheduled_user_dishes: ScheduledUserDishType[]; +} + +export type ScheduleDataType = { + user: UserType; + scheduled_user_dish: UserDishType | null; + user_dishes: UserDishType[]; +} \ No newline at end of file diff --git a/frontend/archive/src/types/ScheduledUserDishType.ts b/frontend/archive/src/types/ScheduledUserDishType.ts new file mode 100644 index 0000000..5ff7c94 --- /dev/null +++ b/frontend/archive/src/types/ScheduledUserDishType.ts @@ -0,0 +1,21 @@ +import {UserType} from "@/types/UserType"; +import {DishType} from "@/types/DishType"; +import {RecurrenceType} from "@/types/ScheduleType"; + +export type UserDishType = { + id: number; + dish: DishType; + user: UserType; + recurrences: RecurrenceType[]; +} + +export type UserDishWithoutUserType = { + id: number; + dish: DishType; + recurrences: RecurrenceType[]; +} + +export type ScheduledUserDishType = { + id: number; + user_dish: UserDishType; +} \ No newline at end of file diff --git a/frontend/archive/src/types/UserDishType.ts b/frontend/archive/src/types/UserDishType.ts new file mode 100644 index 0000000..32f9dfc --- /dev/null +++ b/frontend/archive/src/types/UserDishType.ts @@ -0,0 +1,8 @@ +import {UserType} from "@/types/UserType"; +import {RecurrenceType} from "@/types/ScheduleType"; + +export type DishType = { + user: UserType; + dish: DishType; + recurrences: RecurrenceType[]; +} \ No newline at end of file diff --git a/frontend/archive/src/types/UserType.ts b/frontend/archive/src/types/UserType.ts new file mode 100644 index 0000000..1129ff0 --- /dev/null +++ b/frontend/archive/src/types/UserType.ts @@ -0,0 +1,7 @@ +import {UserDishWithoutUserType} from "@/types/ScheduledUserDishType"; + +export type UserType = { + id: number; + name: string; + user_dishes: UserDishWithoutUserType[]; +}; \ No newline at end of file diff --git a/frontend/archive/src/utils/api/apiRequest.ts b/frontend/archive/src/utils/api/apiRequest.ts new file mode 100644 index 0000000..e0690fa --- /dev/null +++ b/frontend/archive/src/utils/api/apiRequest.ts @@ -0,0 +1,107 @@ +export const apiRequest = async (url: string, options: RequestInit = {}) => { + const token = localStorage.getItem('token'); + + const allowedRequests = [ + '/api/auth/login', + '/api/auth/register', + ] + + if (allowedRequests.includes(url)) { + return publicRequest(url, options) + } + + if (!token) { + throw new Error('No authentication token found.' + url); + } + + return privateRequest(url, token, options); +}; + +export const publicRequest = async (fullUrl: string, options: RequestInit = {}) => { + console.log('→ Sending request', fullUrl, options.method); + + const url = `${process.env.NEXT_PUBLIC_API_URL}${fullUrl}`; + + const response = await fetch(url, { + headers: { + ...(options.headers || {}), + }, + ...options, + }); + + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +export const privateRequest = async (fullUrl: string, token: string, options: RequestInit = {}) => { + const headers = { + ...(options.headers || {}), + Authorization: `Bearer ${token}`, + }; + + const url = `${process.env.NEXT_PUBLIC_API_URL}${fullUrl}`; + + const response = await fetch(url, { headers, ...options }); + + // Authentication failure - token invalid - redirect to login + if (response.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + + window.location.href = '/login'; + + throw new Error('Unauthorized. Redirecting to login.'); + } + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + + +// Add shorthand HTTP methods +apiRequest.get = (url: string, options: RequestInit = {}) => { + return apiRequest(url, { ...options, method: 'GET' }); +}; + +apiRequest.post = | undefined>( + url: string, + body: TBody, + options: RequestInit = {} +) => { + return apiRequest(url, { + ...options, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); +}; + +apiRequest.put = | undefined>( + url: string, + body: TBody, + options: RequestInit = {} +) => { + return apiRequest(url, { + ...options, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); +}; + +apiRequest.delete = (url: string, options: RequestInit = {}) => { + return apiRequest(url, { ...options, method: 'DELETE' }); +}; \ No newline at end of file diff --git a/frontend/archive/src/utils/api/auth.ts b/frontend/archive/src/utils/api/auth.ts new file mode 100644 index 0000000..f4511aa --- /dev/null +++ b/frontend/archive/src/utils/api/auth.ts @@ -0,0 +1,28 @@ +import { apiRequest } from '@/utils/api/apiRequest'; + +export const login = async (email: string, password: string) => { + const data = await apiRequest.post('/api/auth/login', { email, password }); + + if (!data.access_token) { + throw new Error('No access token returned from login.'); + } + + localStorage.setItem('token', data.access_token); + + return data; +}; + + +export const register = async (name: string, email: string, password: string, passwordConfirmation: string) => { + const data = await apiRequest.post('/api/auth/register', { + name, + email, + password, + password_confirmation: passwordConfirmation, // Match the backend's expected parameter + }); + + // Store the token (if returned by the backend) similarly to login + localStorage.setItem('token', data.access_token); + + return data; +}; diff --git a/frontend/archive/src/utils/api/dishApi.ts b/frontend/archive/src/utils/api/dishApi.ts new file mode 100644 index 0000000..8d38dba --- /dev/null +++ b/frontend/archive/src/utils/api/dishApi.ts @@ -0,0 +1,149 @@ +import {DishType} from "@/types/DishType"; +import {apiRequest} from "@/utils/api/apiRequest"; + +export const listDishes = async (): Promise => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/dishes`, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .then((data) => { + if (data?.payload?.dishes) { + return data.payload.dishes as DishType[]; + } + throw new Error('SOMETHING WENT WRONG'); + }) + .catch((error) => { + throw error; + }); +}; + +export const fetchDish = async (id: number): Promise => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/dishes/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .then((data) => { + if (data?.payload?.dish) { + return data.payload.dish as DishType; + } + throw new Error('SOMETHING WENT WRONG'); + }) + .catch((error) => { + throw error; + }); +}; + +export const createDish = async ( + name: string, + // recurrence: number, + // userIds: number[] +) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.post(`/api/dishes`, { + name, + // recurrence, + // users: userIds, + }, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).catch(() => { + throw new Error("Failed to create dish. Please try again later."); + }); +}; + +export const updateDish = async (dish_id: number, name: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.put(`/api/dishes/${dish_id}`, {name}, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .catch((error) => { + throw error; + }); +}; + +export const deleteDish = async (id: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.delete(`/api/dishes/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .then((data) => { + return data; + }) + .catch((error) => { + throw error; + }); +}; + +export const addUserToDish = async (dish_id: number, user_id: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.post(`/api/dishes/${dish_id}/users/add`, { + users: [user_id], + }, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((data) => { + return data; + }) + .catch((error) => { + throw error; + }); +}; + +export const removeUserFromDish = async (dish_id: number, user_id: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.post(`/api/dishes/${dish_id}/users/remove`, { + users: [user_id], + }, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((data) => { + return data; + }) + .catch((error) => { + throw error; + }); +}; diff --git a/frontend/archive/src/utils/api/scheduleApi.ts b/frontend/archive/src/utils/api/scheduleApi.ts new file mode 100644 index 0000000..d7fcbf3 --- /dev/null +++ b/frontend/archive/src/utils/api/scheduleApi.ts @@ -0,0 +1,112 @@ +import { apiRequest } from "@/utils/api/apiRequest"; +import { isValidDate } from "@/helpers/Date"; + +export const listSchedule = async (startDate?: string, endDate?: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + if (startDate && !isValidDate(startDate)) { + throw new Error('Invalid start date'); + } + if (endDate && !isValidDate(endDate)) { + throw new Error('Invalid end date'); + } + + const params = new URLSearchParams(); + if (startDate) params.append('start', startDate); + if (endDate) params.append('end', endDate); + + const endpoint = `/api/schedule${params.toString() ? `?${params.toString()}` : ''}`; + + return apiRequest.get(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduleData) => scheduleData?.payload?.schedule); +}; + +export const getScheduleForDate = async (date: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + if (!isValidDate(date)) { + throw new Error('Invalid date'); + } + + const endpoint = `/api/schedule/${date}`; + + return apiRequest.get(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduleData) => scheduleData?.payload?.schedule) + .catch((err) => { + throw err; + }); +}; + +// Update the schedule for a specific date (e.g., mark as skipped) +export const updateScheduleForDate = async (date: string, isSkipped: boolean) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + if (!isValidDate(date)) { + throw new Error('Invalid date'); + } + + const endpoint = `/api/schedule/${date}`; + + return apiRequest.put(endpoint, { is_skipped: isSkipped }, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduleData) => scheduleData?.payload?.schedule) + .catch((err) => { + throw err; + }); +}; + +// Generate a new schedule (optional: overwrite the existing one) +export const generateSchedule = async (overwrite: boolean) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + const endpoint = `/api/schedule/generate`; + + return apiRequest.post(endpoint, { overwrite }, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduleData) => scheduleData?.payload?.schedule) + .catch((err) => { + throw err; + }); +}; + +export const scheduleUserDish = async (date: string, user_id: number, user_dish_id: number|null, skipped: boolean = false) => { + const token = localStorage.getItem('token'); + + if (!token) throw new Error('No token found in localStorage.'); + + const endpoint = `/api/schedule/${date}/user-dishes`; + + return apiRequest.post(endpoint, { user_dish_id, user_id, skipped }, { headers: { Authorization: `Bearer ${token}`} }) + .then((scheduleData) => scheduleData?.payload?.schedule) + .catch((err) => { throw err }); +}; \ No newline at end of file diff --git a/frontend/archive/src/utils/api/scheduledUserDishesApi.ts b/frontend/archive/src/utils/api/scheduledUserDishesApi.ts new file mode 100644 index 0000000..8a9b66c --- /dev/null +++ b/frontend/archive/src/utils/api/scheduledUserDishesApi.ts @@ -0,0 +1,77 @@ +import { apiRequest } from "@/utils/api/apiRequest"; + +export const listScheduledUserDishesStartingFromDate = async (startDate: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/scheduled-user-dishes?start=${startDate}`, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduledDishesData) => scheduledDishesData?.payload?.schedule) + .catch((err) => { + throw err; + }); +}; + +export const listScheduledUserDishesEndingAtDate = async (endDate: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/scheduled-user-dishes?end=${endDate}`, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduledDishesData) => scheduledDishesData?.payload?.schedule) + .catch((err) => { + throw err; + }); +}; + +export const getScheduledUserDish = async (id: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/scheduled-user-dishes/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduledDishesData) => scheduledDishesData?.payload?.scheduled_user_dish) + .catch((err) => { + throw err; + }); +}; + +export const updateScheduledUserDish = async (id: number, userDishId: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + const payload = userDishId > 0 + ? { user_dish_id: userDishId } + : { user_dish_id: null, is_skipped: true }; + + return apiRequest.put(`/api/scheduled-user-dishes/${id}`, payload, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((scheduledDishesData) => scheduledDishesData?.payload?.scheduled_user_dish) + .catch((err) => { + throw err; + }); +}; \ No newline at end of file diff --git a/frontend/archive/src/utils/api/userDishApi.ts b/frontend/archive/src/utils/api/userDishApi.ts new file mode 100644 index 0000000..39ba27f --- /dev/null +++ b/frontend/archive/src/utils/api/userDishApi.ts @@ -0,0 +1,18 @@ +import { apiRequest } from "@/utils/api/apiRequest"; +import { UserDishType } from "@/types/ScheduledUserDishType"; + +export const listUserDishes = async (): Promise => { + const token = localStorage.getItem('token'); + + if (!token) throw new Error('No token found in localStorage.'); + + return apiRequest.get(`/api/user-dishes`, { headers: { Authorization: `Bearer ${ token }` } }) + .then((data) => { + if (data?.payload?.user_dishes) return data.payload.user_dishes as UserDishType[]; + + throw new Error('SOMETHING WENT WRONG'); + }) + .catch((error) => { + throw error; + }); +}; diff --git a/frontend/archive/src/utils/api/usersApi.ts b/frontend/archive/src/utils/api/usersApi.ts new file mode 100644 index 0000000..8d70ed8 --- /dev/null +++ b/frontend/archive/src/utils/api/usersApi.ts @@ -0,0 +1,139 @@ +import {RecurrenceType} from "@/types/ScheduleType"; +import {apiRequest} from "@/utils/api/apiRequest"; +import {UserType} from "@/types/UserType"; + +export const listUsers = async () => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/users`, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .then((data) => { + if (data?.payload?.users) { + return data.payload.users; + } + throw new Error('Failed to fetch users'); + }) + .catch((err) => { + throw err; + }); +}; + +export const showUser = async (userId: number) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(`/api/users/${userId}`, { + headers: { + Authorization: `Bearer ${token}`, + } + }) + .then((data) => { + if (data?.payload?.user) { + return data.payload.user; + } + throw new Error('Failed to fetch users'); + }) + .catch((err) => { + throw err; + }); +}; + +export const createUser = async (name: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return await apiRequest.post('/api/users', {name}, { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); +}; + +export const updateUser = async (user: UserType, name: string) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return await apiRequest.put(`/api/users/${user.id}`, { name }, { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); +}; + +export const deleteUser = async (user: UserType) => { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return await apiRequest.delete(`/api/users/${user.id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); +}; + + +export const getUserDishForUserAndDish = async (userId: number, dishId: number) => { + const endpoint = `/api/users/${userId}/dishes/${dishId}`; + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.get(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + }}) + .then((data) => { + if (data?.payload?.user_dish) { + return data.payload.user_dish; + } + throw new Error('Failed to fetch user dish'); + }) + .catch((err) => { + throw err; + }); +}; + +export const syncUserDishRecurrences = async ( + dish_id: number, + user_id: number, + recurrenceData: RecurrenceType[] +) => { + const url = `/api/users/${user_id}/dishes/${dish_id}/recurrences`; + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('No token found in localStorage.'); + } + + return apiRequest.post(url, { recurrences: recurrenceData }, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).catch((err) => { + throw err; + }); +}; + diff --git a/frontend/archive/src/utils/dateBuilder.ts b/frontend/archive/src/utils/dateBuilder.ts new file mode 100644 index 0000000..e41ce15 --- /dev/null +++ b/frontend/archive/src/utils/dateBuilder.ts @@ -0,0 +1,24 @@ +import { DateTime } from "luxon"; + +const transformDate = (inputDate: string, addSuffix = false): string => { + const date = DateTime.fromISO(inputDate) + const day = date.day + const suffix = addSuffix ? getDaySuffix(day) : '' + return date.toFormat("MMMM") + ` ${ day }${ suffix }, ` + date.toFormat("yyyy"); +} + +const getDaySuffix = (day: number): string => { + if (day >= 11 && day <= 13) return "th"; + switch (day % 10) { + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; + } +}; + +export default transformDate; \ No newline at end of file diff --git a/frontend/archive/src/utils/scheduleBuilder.ts b/frontend/archive/src/utils/scheduleBuilder.ts new file mode 100644 index 0000000..8bbf15f --- /dev/null +++ b/frontend/archive/src/utils/scheduleBuilder.ts @@ -0,0 +1,19 @@ +import { ScheduleDataType, ScheduleType } from "@/types/ScheduleType"; +import { ScheduledUserDishType, UserDishType } from "@/types/ScheduledUserDishType"; +import { UserType } from "@/types/UserType"; + +const ScheduleBuilder = ( + schedule: ScheduleType, + users: UserType[], + userDishes: UserDishType[] +): ScheduleDataType[] => users.map(user => { + return { + user, + scheduled_user_dish: schedule.scheduled_user_dishes + .filter((scheduledUserDish: ScheduledUserDishType) => scheduledUserDish.user_dish?.user.id === user.id) + .shift()?.user_dish ?? null, + user_dishes: userDishes.filter((userDish: UserDishType) => userDish.user.id === user.id) + } +}) + +export default ScheduleBuilder \ No newline at end of file diff --git a/frontend/archive/tailwind.config.ts b/frontend/archive/tailwind.config.ts new file mode 100644 index 0000000..109807b --- /dev/null +++ b/frontend/archive/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from "tailwindcss"; + +export default { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: "var(--background)", + foreground: "var(--foreground)", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/frontend/archive/tsconfig.json b/frontend/archive/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/frontend/archive/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ca4715b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4828 @@ +{ + "name": "my-react-router-app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-react-router-app", + "dependencies": { + "@react-router/node": "^7.5.3", + "@react-router/serve": "^7.5.3", + "isbot": "^5.1.27", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.5.3" + }, + "devDependencies": { + "@react-router/dev": "^7.5.3", + "@tailwindcss/vite": "^4.1.4", + "@types/node": "^20", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "tailwindcss": "^4.1.4", + "typescript": "^5.8.3", + "vite": "^6.3.3", + "vite-tsconfig-paths": "^5.1.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", + "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", + "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" + }, + "node_modules/@npmcli/git": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.1.0.tgz", + "integrity": "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^6.0.0", + "lru-cache": "^7.4.4", + "npm-pick-manifest": "^8.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@npmcli/package-json": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-4.0.1.tgz", + "integrity": "sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^4.1.0", + "glob": "^10.2.2", + "hosted-git-info": "^6.1.1", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "proc-log": "^3.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz", + "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@react-router/dev": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.6.0.tgz", + "integrity": "sha512-XSxEslex0ddJPxNNgdU1Eqmc9lsY/lhcLNCcRLAtlrOPyOz3Y8kIPpAf5T/U2AG3HGXFVBa9f8aQ7wXU3wTJSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.8", + "@babel/generator": "^7.21.5", + "@babel/parser": "^7.21.8", + "@babel/plugin-syntax-decorators": "^7.22.10", + "@babel/plugin-syntax-jsx": "^7.21.4", + "@babel/preset-typescript": "^7.21.5", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.22.5", + "@npmcli/package-json": "^4.0.1", + "@react-router/node": "7.6.0", + "arg": "^5.0.1", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^4.0.0", + "dedent": "^1.5.3", + "es-module-lexer": "^1.3.1", + "exit-hook": "2.2.1", + "fs-extra": "^10.0.0", + "jsesc": "3.0.2", + "lodash": "^4.17.21", + "pathe": "^1.1.2", + "picocolors": "^1.1.1", + "prettier": "^2.7.1", + "react-refresh": "^0.14.0", + "semver": "^7.3.7", + "set-cookie-parser": "^2.6.0", + "valibot": "^0.41.0", + "vite-node": "3.0.0-beta.2" + }, + "bin": { + "react-router": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/serve": "^7.6.0", + "react-router": "^7.6.0", + "typescript": "^5.1.0", + "vite": "^5.1.0 || ^6.0.0", + "wrangler": "^3.28.2 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/express": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.6.0.tgz", + "integrity": "sha512-nxSTCcTsVx94bXOI9JjG7Cg338myi8EdQWTOjA97v2ApX35wZm/ZDYos5MbrvZiMi0aB4KgAD62o4byNqF9Z1A==", + "license": "MIT", + "dependencies": { + "@react-router/node": "7.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "express": "^4.17.1 || ^5", + "react-router": "7.6.0", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/node": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.6.0.tgz", + "integrity": "sha512-agjDPUzisLdGJ7Q2lx/Z3OfdS2t1k6qv/nTvA45iahGsQJCMDvMqVoIi7iIULKQJwrn4HWjM9jqEp75+WsMOXg==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0", + "source-map-support": "^0.5.21", + "stream-slice": "^0.1.2", + "undici": "^6.19.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.6.0", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@react-router/serve": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.6.0.tgz", + "integrity": "sha512-2O8ALEYgJfimvEdNRqMpnZb2N+DQ5UK/SKo9Xo3mTkt3no0rNTcNxzmhzD2tm92Q/HI7kHmMY1nBegNB2i1abA==", + "license": "MIT", + "dependencies": { + "@react-router/express": "7.6.0", + "@react-router/node": "7.6.0", + "compression": "^1.7.4", + "express": "^4.19.2", + "get-port": "5.1.1", + "morgan": "^1.10.0", + "source-map-support": "^0.5.21" + }, + "bin": { + "react-router-serve": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.6.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.6.tgz", + "integrity": "sha512-ed6zQbgmKsjsVvodAS1q1Ld2BolEuxJOSyyNc+vhkjdmfNUDCmQnlXBfQkHrlzNmslxHsQU/bFmzcEbv4xXsLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.6" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.6.tgz", + "integrity": "sha512-0bpEBQiGx+227fW4G0fLQ8vuvyy5rsB1YIYNapTq3aRsJ9taF3f5cCaovDjN5pUGKKzcpMrZst/mhNaKAPOHOA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.6", + "@tailwindcss/oxide-darwin-arm64": "4.1.6", + "@tailwindcss/oxide-darwin-x64": "4.1.6", + "@tailwindcss/oxide-freebsd-x64": "4.1.6", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.6", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.6", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.6", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.6", + "@tailwindcss/oxide-linux-x64-musl": "4.1.6", + "@tailwindcss/oxide-wasm32-wasi": "4.1.6", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.6", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.6" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.6.tgz", + "integrity": "sha512-VHwwPiwXtdIvOvqT/0/FLH/pizTVu78FOnI9jQo64kSAikFSZT7K4pjyzoDpSMaveJTGyAKvDjuhxJxKfmvjiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.6.tgz", + "integrity": "sha512-weINOCcqv1HVBIGptNrk7c6lWgSFFiQMcCpKM4tnVi5x8OY2v1FrV76jwLukfT6pL1hyajc06tyVmZFYXoxvhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.6.tgz", + "integrity": "sha512-3FzekhHG0ww1zQjQ1lPoq0wPrAIVXAbUkWdWM8u5BnYFZgb9ja5ejBqyTgjpo5mfy0hFOoMnMuVDI+7CXhXZaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.6.tgz", + "integrity": "sha512-4m5F5lpkBZhVQJq53oe5XgJ+aFYWdrgkMwViHjRsES3KEu2m1udR21B1I77RUqie0ZYNscFzY1v9aDssMBZ/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.6.tgz", + "integrity": "sha512-qU0rHnA9P/ZoaDKouU1oGPxPWzDKtIfX7eOGi5jOWJKdxieUJdVV+CxWZOpDWlYTd4N3sFQvcnVLJWJ1cLP5TA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.6.tgz", + "integrity": "sha512-jXy3TSTrbfgyd3UxPQeXC3wm8DAgmigzar99Km9Sf6L2OFfn/k+u3VqmpgHQw5QNfCpPe43em6Q7V76Wx7ogIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.6.tgz", + "integrity": "sha512-8kjivE5xW0qAQ9HX9reVFmZj3t+VmljDLVRJpVBEoTR+3bKMnvC7iLcoSGNIUJGOZy1mLVq7x/gerVg0T+IsYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.6.tgz", + "integrity": "sha512-A4spQhwnWVpjWDLXnOW9PSinO2PTKJQNRmL/aIl2U/O+RARls8doDfs6R41+DAXK0ccacvRyDpR46aVQJJCoCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.6.tgz", + "integrity": "sha512-YRee+6ZqdzgiQAHVSLfl3RYmqeeaWVCk796MhXhLQu2kJu2COHBkqlqsqKYx3p8Hmk5pGCQd2jTAoMWWFeyG2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.6.tgz", + "integrity": "sha512-qAp4ooTYrBQ5pk5jgg54/U1rCJ/9FLYOkkQ/nTE+bVMseMfB6O7J8zb19YTpWuu4UdfRf5zzOrNKfl6T64MNrQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.9", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.6.tgz", + "integrity": "sha512-nqpDWk0Xr8ELO/nfRUDjk1pc9wDJ3ObeDdNMHLaymc4PJBWj11gdPCWZFKSK2AVKjJQC7J2EfmSmf47GN7OuLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.6.tgz", + "integrity": "sha512-5k9xF33xkfKpo9wCvYcegQ21VwIBU1/qEbYlVukfEIyQbEA47uK8AAwS7NVjNE3vHzcmxMYwd0l6L4pPjjm1rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.6.tgz", + "integrity": "sha512-zjtqjDeY1w3g2beYQtrMAf51n5G7o+UwmyOjtsDMP7t6XyoRMOidcoKP32ps7AkNOHIXEOK0bhIC05dj8oJp4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.6", + "@tailwindcss/oxide": "4.1.6", + "tailwindcss": "4.1.6" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.17.46", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.46.tgz", + "integrity": "sha512-0PQHLhZPWOxGW4auogW0eOQAuNIlCYvibIpG67ja0TOJ6/sehu+1en7sfceUn+QQtx4Rk3GxbLNwPh0Cav7TWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/react": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", + "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.4.tgz", + "integrity": "sha512-WxYAszDYgsMV31OVyoG4jbAgJI1Gw0Xq9V19zwhy6+hUUJlJIdZ3r/cbdmTqFv++SktQkZ/X+46yGFxp5XJBEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", + "integrity": "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001717", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", + "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.152", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.152.tgz", + "integrity": "sha512-xBOfg/EBaIlVsHipHl2VdTPJRSvErNUaqW8ejTq5OlOlIYx1wOllCHsAvAIrr55jD1IYEfdR86miUEt8H5IeJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.3.tgz", + "integrity": "sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isbot": { + "version": "5.1.28", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.28.tgz", + "integrity": "sha512-qrOp4g3xj8YNse4biorv6O5ZShwsJM0trsoda4y7j/Su7ZtTTfVXFzbKkpgcSoDrHS8FcTuUwcU04YimZlZOxw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz", + "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^6.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-install-checks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", + "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz", + "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.2.tgz", + "integrity": "sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^10.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", + "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-slice": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", + "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.6.tgz", + "integrity": "sha512-j0cGLTreM6u4OWzBeLBpycK0WIh8w7kSwcUsQZoGLHZ7xDTdM69lN64AgoIEEwFi0tnhs4wSykUa5YWxAzgFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsconfck": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", + "integrity": "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.21.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", + "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/valibot": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.41.0.tgz", + "integrity": "sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.0.0-beta.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.0-beta.2.tgz", + "integrity": "sha512-ofTf6cfRdL30Wbl9n/BX81EyIR5s4PReLmSurrxQ+koLaWUNOEo8E0lCM53OJkb8vpa2URM2nSrxZsIFyvY1rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1337f19 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "my-react-router-app", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/node": "^7.5.3", + "@react-router/serve": "^7.5.3", + "isbot": "^5.1.27", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.5.3" + }, + "devDependencies": { + "@react-router/dev": "^7.5.3", + "@tailwindcss/vite": "^4.1.4", + "@types/node": "^20", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "tailwindcss": "^4.1.4", + "typescript": "^5.8.3", + "vite": "^6.3.3", + "vite-tsconfig-paths": "^5.1.4" + } +} \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..5dbdfcd Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/react-router.config.ts b/frontend/react-router.config.ts new file mode 100644 index 0000000..6ff16f9 --- /dev/null +++ b/frontend/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + // Config options... + // Server-side render by default, to enable SPA mode set this to `false` + ssr: true, +} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..dc391a4 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4a88d58 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from "@react-router/dev/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +});